brainiac-basecamp 0.0.20 → 0.0.22
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 +227 -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/version.rb +1 -1
- 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: cbdaed237c170e246708af6b6f249cc90897b58aa3551eeb563e2a154145b49f
|
|
4
|
+
data.tar.gz: ab6c55d1ef2f88e26194059cb453fda42c3660777ab34813d15b6e88fd5afaf5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 46f0a19b34106661205fc2cc0dc6b921511a04a7a3a2b6df2665c8d1034afc937029ac5b00b11fe3e7f42b52d8fe24bd48ef29705b15211ed76deb8e05bfde8d
|
|
7
|
+
data.tar.gz: a278fc0d456893561a8e320ee223510ef338296605a66e1871099514f711cf3989e2d000f3864371097105c3af6a0afd332ddf87a70910870f45a488cbb1f509
|
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,119 @@ 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
|
+
# Pull latest changes before deploying
|
|
288
|
+
puts "Pulling latest changes..."
|
|
289
|
+
pull_output, pull_status = Open3.capture2e("git", "pull", "--ff-only", chdir: worktree_path)
|
|
290
|
+
if pull_status.success?
|
|
291
|
+
if pull_output.include?("Already up to date")
|
|
292
|
+
puts "Already up to date."
|
|
293
|
+
else
|
|
294
|
+
puts pull_output.lines.first(5).join
|
|
295
|
+
end
|
|
296
|
+
else
|
|
297
|
+
puts "⚠️ Git pull failed (continuing anyway):"
|
|
298
|
+
puts pull_output.lines.first(3).join
|
|
299
|
+
end
|
|
300
|
+
puts ""
|
|
301
|
+
|
|
302
|
+
# Run deploy
|
|
303
|
+
success = run_deploy(worktree_path, deploy_env)
|
|
304
|
+
|
|
305
|
+
puts ""
|
|
306
|
+
|
|
307
|
+
if success
|
|
308
|
+
puts "✅ Deploy completed successfully"
|
|
309
|
+
|
|
310
|
+
# Update epic state
|
|
311
|
+
epic["last_deploy"] = {
|
|
312
|
+
"env" => deploy_env,
|
|
313
|
+
"at" => Time.now.iso8601,
|
|
314
|
+
"status" => "success"
|
|
315
|
+
}
|
|
316
|
+
else
|
|
317
|
+
puts "❌ Deploy failed"
|
|
318
|
+
|
|
319
|
+
epic["last_deploy"] = {
|
|
320
|
+
"env" => deploy_env,
|
|
321
|
+
"at" => Time.now.iso8601,
|
|
322
|
+
"status" => "failed"
|
|
323
|
+
}
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
save_epics(epics)
|
|
327
|
+
|
|
328
|
+
# Change into the worktree directory
|
|
329
|
+
puts ""
|
|
330
|
+
puts "Entering worktree..."
|
|
331
|
+
Dir.chdir(worktree_path)
|
|
332
|
+
exec ENV.fetch("SHELL", "/bin/bash")
|
|
333
|
+
end
|
|
334
|
+
|
|
206
335
|
def cmd_link(args)
|
|
207
336
|
fizzy_card = args.shift
|
|
208
337
|
basecamp_url = args.shift
|
|
@@ -517,7 +646,8 @@ module Brainiac
|
|
|
517
646
|
setup Interactive setup guide
|
|
518
647
|
config Show current config
|
|
519
648
|
status Check plugin status
|
|
520
|
-
epics [--all]
|
|
649
|
+
epics [--all] [-v] List active epics (--all for completed too)
|
|
650
|
+
deploy <epic-id> [env] Deploy epic worktree to environment
|
|
521
651
|
link <card> <url> Link a Fizzy card to a Basecamp todo
|
|
522
652
|
bot add <agent-name> Add bot (auto-resolves person_id from Basecamp)
|
|
523
653
|
bot add <name> <person-id> <agent> Add bot with explicit person_id
|
|
@@ -542,6 +672,13 @@ module Brainiac
|
|
|
542
672
|
on_complete — Advance to next task as soon as agent finishes (default)
|
|
543
673
|
on_pr_merge — Wait for PR to be merged before advancing (review gate)
|
|
544
674
|
|
|
675
|
+
Deploy environment resolution (in order):
|
|
676
|
+
1. Explicit env argument: brainiac basecamp deploy 12345 dev02
|
|
677
|
+
2. [deploy:env] in epic title: "Epic: My Feature [deploy:dev02]"
|
|
678
|
+
3. deploy:env in epic description
|
|
679
|
+
4. deploy.project_envs.<project> in config (per-project default)
|
|
680
|
+
5. deploy.default_env in config (global default)
|
|
681
|
+
|
|
545
682
|
Task statuses (for reset --to):
|
|
546
683
|
pending — Not yet started, waiting for dependencies
|
|
547
684
|
in_flight — Agent is actively working on it
|
|
@@ -873,6 +1010,91 @@ module Brainiac
|
|
|
873
1010
|
}))
|
|
874
1011
|
end
|
|
875
1012
|
|
|
1013
|
+
# --- Deploy helpers ---
|
|
1014
|
+
|
|
1015
|
+
def list_epics_for_deploy
|
|
1016
|
+
epics = load_epics.select { |e| e["status"] == "active" }
|
|
1017
|
+
if epics.empty?
|
|
1018
|
+
puts " (no active epics)"
|
|
1019
|
+
return
|
|
1020
|
+
end
|
|
1021
|
+
|
|
1022
|
+
epics.each do |epic|
|
|
1023
|
+
deploy_env = resolve_deploy_env(epic, nil)
|
|
1024
|
+
env_display = deploy_env ? "[deploy:#{deploy_env}]" : "(no env)"
|
|
1025
|
+
puts " #{epic['basecamp_todolist_id']} — #{epic['title']} #{env_display}"
|
|
1026
|
+
end
|
|
1027
|
+
end
|
|
1028
|
+
|
|
1029
|
+
def resolve_deploy_env(epic, override)
|
|
1030
|
+
# 1. Explicit override
|
|
1031
|
+
return override if override && !override.empty?
|
|
1032
|
+
|
|
1033
|
+
# 2. [deploy:env] in title or deploy:env in description
|
|
1034
|
+
env = Basecamp::Epic.extract_deploy_env(epic["title"], epic["description"])
|
|
1035
|
+
return env if env
|
|
1036
|
+
|
|
1037
|
+
# 3. Per-project default from config
|
|
1038
|
+
# Prefer the project from tasks (set from Fizzy card tags) over basecamp mapping
|
|
1039
|
+
# since multiple brainiac projects can share the same basecamp project.
|
|
1040
|
+
project_key = (epic["tasks"] || []).filter_map { |t| t["project"] }.first
|
|
1041
|
+
project_key ||= Config.brainiac_project_for(epic["basecamp_project_id"])
|
|
1042
|
+
project_env = Config.deploy.dig("project_envs", project_key) if project_key
|
|
1043
|
+
return project_env if project_env
|
|
1044
|
+
|
|
1045
|
+
# 4. Global default
|
|
1046
|
+
Config.deploy["default_env"]
|
|
1047
|
+
end
|
|
1048
|
+
|
|
1049
|
+
def find_epic_worktree(epic)
|
|
1050
|
+
# Epic branches are stored per-project in epic["epic_branches"]
|
|
1051
|
+
# Find any worktree that has the epic branch checked out
|
|
1052
|
+
epic_branches = epic["epic_branches"] || {}
|
|
1053
|
+
return nil if epic_branches.empty?
|
|
1054
|
+
|
|
1055
|
+
# Load projects to find repo paths
|
|
1056
|
+
projects_file = File.join(BRAINIAC_DIR, "projects.json")
|
|
1057
|
+
return nil unless File.exist?(projects_file)
|
|
1058
|
+
|
|
1059
|
+
projects = JSON.parse(File.read(projects_file))
|
|
1060
|
+
|
|
1061
|
+
epic_branches.each do |project_key, branch_name|
|
|
1062
|
+
repo_path = projects.dig(project_key, "repo_path")
|
|
1063
|
+
next unless repo_path && File.directory?(repo_path)
|
|
1064
|
+
|
|
1065
|
+
# List worktrees and find one with this branch
|
|
1066
|
+
output, status = Open3.capture2("git", "worktree", "list", "--porcelain", chdir: repo_path)
|
|
1067
|
+
next unless status.success?
|
|
1068
|
+
|
|
1069
|
+
# Parse porcelain output
|
|
1070
|
+
current_worktree = nil
|
|
1071
|
+
output.each_line do |line|
|
|
1072
|
+
if line.start_with?("worktree ")
|
|
1073
|
+
current_worktree = line.sub("worktree ", "").strip
|
|
1074
|
+
elsif line.start_with?("branch refs/heads/")
|
|
1075
|
+
wt_branch = line.sub("branch refs/heads/", "").strip
|
|
1076
|
+
return current_worktree if wt_branch == branch_name && current_worktree && current_worktree != repo_path
|
|
1077
|
+
end
|
|
1078
|
+
end
|
|
1079
|
+
end
|
|
1080
|
+
|
|
1081
|
+
nil
|
|
1082
|
+
rescue JSON::ParserError
|
|
1083
|
+
nil
|
|
1084
|
+
end
|
|
1085
|
+
|
|
1086
|
+
def run_deploy(worktree_path, env)
|
|
1087
|
+
deploy_config = Config.deploy
|
|
1088
|
+
command_template = deploy_config["command"] || "belt deploy {env} --auto"
|
|
1089
|
+
command = command_template.gsub("{env}", env)
|
|
1090
|
+
|
|
1091
|
+
puts "Running: #{command}"
|
|
1092
|
+
puts "-" * 40
|
|
1093
|
+
|
|
1094
|
+
# Run the deploy command in the worktree directory
|
|
1095
|
+
system(command, chdir: worktree_path)
|
|
1096
|
+
end
|
|
1097
|
+
|
|
876
1098
|
def auto_resolve_person_id(agent_name)
|
|
877
1099
|
output, status = Open3.capture2(
|
|
878
1100
|
"basecamp", "people", "list", "--jq",
|
|
@@ -928,7 +1150,7 @@ module Brainiac
|
|
|
928
1150
|
end
|
|
929
1151
|
|
|
930
1152
|
def self.completions
|
|
931
|
-
%w[setup config status epics link bot projects set reset webhook]
|
|
1153
|
+
%w[setup config status epics deploy link bot projects set reset webhook]
|
|
932
1154
|
end
|
|
933
1155
|
end
|
|
934
1156
|
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
|