brainiac 0.0.7 → 0.0.8
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/Gemfile.lock +2 -2
- data/bin/brainiac +132 -75
- data/lib/brainiac/agents.rb +12 -36
- data/lib/brainiac/brain.rb +6 -6
- data/lib/brainiac/config.rb +2 -67
- data/lib/brainiac/handlers/discord/config.rb +1 -1
- data/lib/brainiac/handlers/discord/delivery.rb +1 -1
- data/lib/brainiac/handlers/discord/gateway.rb +1 -1
- data/lib/brainiac/handlers/discord/message.rb +10 -10
- data/lib/brainiac/handlers/discord/reactions.rb +1 -1
- data/lib/brainiac/handlers/github.rb +49 -169
- data/lib/brainiac/handlers/shared/git.rb +4 -17
- data/lib/brainiac/handlers/shared/inline_tags.rb +1 -1
- data/lib/brainiac/handlers/zoho.rb +29 -82
- data/lib/brainiac/helpers.rb +43 -336
- data/lib/brainiac/hooks.rb +86 -0
- data/lib/brainiac/plugins.rb +27 -2
- data/lib/brainiac/prompts.rb +34 -172
- data/lib/brainiac/restart.rb +30 -12
- data/lib/brainiac/routes/api.rb +2 -18
- data/lib/brainiac/users.rb +1 -7
- data/lib/brainiac/version.rb +1 -1
- data/lib/brainiac.rb +1 -1
- data/monitor/waybar/deploy_env.rb +3 -3
- data/monitor/xbar/plugin.rb +11 -17
- data/receiver.rb +9 -157
- data/templates/agents.json.example +1 -2
- data/templates/brainiac.json.example +0 -1
- data/templates/users.json.example +0 -3
- metadata +2 -10
- data/lib/brainiac/handlers/fizzy/assignment.rb +0 -125
- data/lib/brainiac/handlers/fizzy/card_index.rb +0 -389
- data/lib/brainiac/handlers/fizzy/comments.rb +0 -730
- data/lib/brainiac/handlers/fizzy/dedup.rb +0 -74
- data/lib/brainiac/handlers/fizzy/deploy.rb +0 -152
- data/lib/brainiac/handlers/fizzy/deployments.rb +0 -260
- data/lib/brainiac/handlers/fizzy.rb +0 -14
- data/lib/brainiac/planning.rb +0 -237
- data/templates/fizzy.json.example +0 -24
data/lib/brainiac/config.rb
CHANGED
|
@@ -15,12 +15,10 @@ BRAINIAC_VERSION = Brainiac::VERSION
|
|
|
15
15
|
|
|
16
16
|
# --- Environment & paths ---
|
|
17
17
|
|
|
18
|
-
FIZZY_WEBHOOK_SECRET = ENV.fetch("FIZZY_WEBHOOK_SECRET", nil)
|
|
19
|
-
|
|
20
18
|
BRAINIAC_DIR = ENV.fetch("BRAINIAC_DIR", File.join(Dir.home, ".brainiac"))
|
|
21
19
|
PROJECTS_FILE = File.join(BRAINIAC_DIR, "projects.json")
|
|
22
20
|
KIRO_AGENTS_DIR = File.join(Dir.home, ".kiro", "agents")
|
|
23
|
-
|
|
21
|
+
WORK_ITEM_MAP_FILE = File.join(BRAINIAC_DIR, "work_items.json")
|
|
24
22
|
AGENT_REGISTRY_FILE = File.join(BRAINIAC_DIR, "agents.json")
|
|
25
23
|
|
|
26
24
|
# --- Master config (handler toggles, global settings) ---
|
|
@@ -80,21 +78,6 @@ KNOWLEDGE_COLLECTION = "brainiac-knowledge"
|
|
|
80
78
|
|
|
81
79
|
ROLES_DIR = File.join(BRAINIAC_DIR, "roles")
|
|
82
80
|
|
|
83
|
-
# --- Fizzy auth ---
|
|
84
|
-
|
|
85
|
-
FIZZY_CONFIG_FILE = File.join(BRAINIAC_DIR, "fizzy.json")
|
|
86
|
-
|
|
87
|
-
def load_fizzy_config
|
|
88
|
-
return {} unless File.exist?(FIZZY_CONFIG_FILE)
|
|
89
|
-
|
|
90
|
-
JSON.parse(File.read(FIZZY_CONFIG_FILE))
|
|
91
|
-
rescue JSON::ParserError => e
|
|
92
|
-
LOG.error "Failed to parse Fizzy config: #{e.message}"
|
|
93
|
-
{}
|
|
94
|
-
end
|
|
95
|
-
|
|
96
|
-
FIZZY_CONFIG = load_fizzy_config
|
|
97
|
-
|
|
98
81
|
# --- GitHub auth ---
|
|
99
82
|
|
|
100
83
|
GITHUB_CONFIG_FILE = File.join(BRAINIAC_DIR, "github.json")
|
|
@@ -115,53 +98,6 @@ def github_webhook_secret
|
|
|
115
98
|
GITHUB_CONFIG["webhook_secret"] || ENV.fetch("GITHUB_WEBHOOK_SECRET", nil)
|
|
116
99
|
end
|
|
117
100
|
|
|
118
|
-
# --- Board config ---
|
|
119
|
-
|
|
120
|
-
FIZZY_BOARDS = FIZZY_CONFIG["boards"] || {}
|
|
121
|
-
|
|
122
|
-
def board_config(board_key)
|
|
123
|
-
FIZZY_BOARDS[board_key.to_s]
|
|
124
|
-
end
|
|
125
|
-
|
|
126
|
-
def board_webhook_secret(board_key)
|
|
127
|
-
config = board_config(board_key)
|
|
128
|
-
config&.dig("webhook_secret") || FIZZY_WEBHOOK_SECRET
|
|
129
|
-
end
|
|
130
|
-
|
|
131
|
-
def board_column_id(board_key, column_name)
|
|
132
|
-
config = board_config(board_key)
|
|
133
|
-
config&.dig("columns", column_name.to_s)
|
|
134
|
-
end
|
|
135
|
-
|
|
136
|
-
# Find board_key by board_id (from .fizzy.yaml or payload)
|
|
137
|
-
def board_key_for_id(board_id)
|
|
138
|
-
FIZZY_BOARDS.each do |key, config|
|
|
139
|
-
return key if config["board_id"] == board_id
|
|
140
|
-
end
|
|
141
|
-
nil
|
|
142
|
-
end
|
|
143
|
-
|
|
144
|
-
# Determine board_key for a project by reading its .fizzy.yaml
|
|
145
|
-
def board_key_for_project(project_config)
|
|
146
|
-
fizzy_yaml = File.join(project_config["repo_path"], ".fizzy.yaml")
|
|
147
|
-
return nil unless File.exist?(fizzy_yaml)
|
|
148
|
-
|
|
149
|
-
require "yaml"
|
|
150
|
-
data = YAML.safe_load_file(fizzy_yaml)
|
|
151
|
-
board_id = data["board"]
|
|
152
|
-
board_key_for_id(board_id)
|
|
153
|
-
rescue StandardError => e
|
|
154
|
-
LOG.warn "Could not read .fizzy.yaml for board lookup: #{e.message}"
|
|
155
|
-
nil
|
|
156
|
-
end
|
|
157
|
-
|
|
158
|
-
# Build authorized user IDs from config or env var (env var overrides)
|
|
159
|
-
AUTHORIZED_USER_IDS = if ENV["AUTHORIZED_USER_IDS"] && !ENV["AUTHORIZED_USER_IDS"].empty?
|
|
160
|
-
ENV["AUTHORIZED_USER_IDS"].split(",").map(&:strip)
|
|
161
|
-
else
|
|
162
|
-
(FIZZY_CONFIG["authorized_users"] || []).map { |u| u["id"] }
|
|
163
|
-
end
|
|
164
|
-
|
|
165
101
|
NOTIFICATION_COMMAND = ENV.fetch("NOTIFICATION_COMMAND", nil)
|
|
166
102
|
|
|
167
103
|
# --- Projects ---
|
|
@@ -212,13 +148,12 @@ PROJECTS = load_projects_config
|
|
|
212
148
|
|
|
213
149
|
DEFAULT_PROJECT = {
|
|
214
150
|
"repo_path" => ENV.fetch("REPO_PATH", Dir.pwd),
|
|
215
|
-
"fizzy_tags" => [],
|
|
216
151
|
"github_repo" => ENV.fetch("GITHUB_REPO", nil),
|
|
217
152
|
# CLI defaults below are overridden by ~/.brainiac/cli-providers/*.json when a
|
|
218
153
|
# cli_provider is configured on the project or agent. These only apply as a
|
|
219
154
|
# last-resort fallback when no provider config exists.
|
|
220
155
|
"agent_cli" => ENV.fetch("AGENT_CLI", "kiro-cli"),
|
|
221
|
-
"agent_cli_args" => ENV.fetch("AGENT_CLI_ARGS", "chat --no-interactive"),
|
|
156
|
+
"agent_cli_args" => ENV.fetch("AGENT_CLI_ARGS", "chat --trust-all-tools --no-interactive"),
|
|
222
157
|
"agent_model_flag" => ENV["AGENT_MODEL_FLAG"] || "--model",
|
|
223
158
|
"agent_model" => ENV.fetch("AGENT_MODEL", nil),
|
|
224
159
|
"agent_effort_flag" => ENV["AGENT_EFFORT_FLAG"] || "--effort",
|
|
@@ -120,7 +120,7 @@ def discord_mention_roster
|
|
|
120
120
|
DISCORD_BOTS.each do |agent_key, info|
|
|
121
121
|
next unless info[:user_id]
|
|
122
122
|
|
|
123
|
-
display =
|
|
123
|
+
display = agent_display_name(agent_key) || agent_key.capitalize
|
|
124
124
|
lines << " - #{display}: `<@#{info[:user_id]}>`"
|
|
125
125
|
end
|
|
126
126
|
end
|
|
@@ -124,7 +124,7 @@ def deliver_to_channel_thread(response, channel_id, message_id, agent_key, agent
|
|
|
124
124
|
|
|
125
125
|
# Tier 3: No thread exists anywhere — create one.
|
|
126
126
|
unless thread_id
|
|
127
|
-
display_name =
|
|
127
|
+
display_name = agent_display_name(agent_key)
|
|
128
128
|
thread = create_discord_thread(channel_id, message_id, name: "#{display_name}: #{clean_content[0..80]}", token: bot_token)
|
|
129
129
|
if thread && thread["id"]
|
|
130
130
|
thread_id = thread["id"]
|
|
@@ -15,7 +15,7 @@ DISCORD_ALL_READY_LOGGED = { done: false }
|
|
|
15
15
|
|
|
16
16
|
def start_discord_gateway_for(agent_key, bot_token)
|
|
17
17
|
Thread.new do
|
|
18
|
-
agent_display =
|
|
18
|
+
agent_display = agent_display_name(agent_key) || agent_key.capitalize
|
|
19
19
|
bot_user_id = nil
|
|
20
20
|
|
|
21
21
|
loop do
|
|
@@ -39,7 +39,7 @@ def handle_discord_message(message, agent_key, bot_token, bot_user_id)
|
|
|
39
39
|
reply_context = build_reply_context(referenced_message)
|
|
40
40
|
discord_user = author["username"]
|
|
41
41
|
discord_user_id = author["id"]
|
|
42
|
-
agent_name =
|
|
42
|
+
agent_name = agent_display_name(agent_key) || agent_key.capitalize
|
|
43
43
|
|
|
44
44
|
channel_info, is_thread, is_dm = ensure_channel_info(channel_info, is_thread, is_dm, channel_id, bot_token)
|
|
45
45
|
parent_channel_id = is_thread ? channel_info&.dig("parent_id") || channel_id : channel_id
|
|
@@ -142,7 +142,7 @@ def dispatch_discord_session(agent_key:, agent_name:, bot_token:, channel_id:, m
|
|
|
142
142
|
root_message = find_root_message(message, channel_id, bot_token)
|
|
143
143
|
card_id = is_thread ? "discord-#{parent_channel_id}-#{channel_id}" : "discord-#{channel_id}-#{root_message[:id]}"
|
|
144
144
|
thread_root_context = build_thread_root_context(is_thread, root_message, parent_channel_id, channel_id, bot_token)
|
|
145
|
-
planning_info =
|
|
145
|
+
planning_info = false
|
|
146
146
|
brain_context = build_brain_context(agent_name: agent_name, card_title: clean_content, comment_body: clean_content)
|
|
147
147
|
|
|
148
148
|
should_resume, thread_worktree_path, thread_cli_provider, thread_model, thread_effort = manage_discord_worktree(
|
|
@@ -348,16 +348,16 @@ def validate_cross_agent_dispatch(sender_agent_key, agent_key, mentioned, conten
|
|
|
348
348
|
return false unless mentioned
|
|
349
349
|
|
|
350
350
|
if content.match?(/created\s+card\s+#?\d+/i) || content.match?(/assigned\s+.*card\s+#?\d+/i) || content.match?(/card\s+#?\d+.*assigned/i)
|
|
351
|
-
sender_display =
|
|
352
|
-
agent_display =
|
|
353
|
-
LOG.info "[Discord:#{agent_display}] Ignoring cross-agent mention from #{sender_display} —
|
|
351
|
+
sender_display = agent_display_name(sender_agent_key) || sender_agent_key.capitalize
|
|
352
|
+
agent_display = agent_display_name(agent_key) || agent_key.capitalize
|
|
353
|
+
LOG.info "[Discord:#{agent_display}] Ignoring cross-agent mention from #{sender_display} — card creation/assignment (handled by webhook)"
|
|
354
354
|
return false
|
|
355
355
|
end
|
|
356
356
|
|
|
357
357
|
depth_key = "discord-#{channel_id}"
|
|
358
358
|
unless agent_dispatch_allowed?(depth_key)
|
|
359
|
-
sender_display =
|
|
360
|
-
agent_display =
|
|
359
|
+
sender_display = agent_display_name(sender_agent_key) || sender_agent_key.capitalize
|
|
360
|
+
agent_display = agent_display_name(agent_key) || agent_key.capitalize
|
|
361
361
|
LOG.info "[Discord:#{agent_display}] Blocking cross-agent dispatch from #{sender_display} — depth limit reached"
|
|
362
362
|
return false
|
|
363
363
|
end
|
|
@@ -425,7 +425,7 @@ def should_stand_down?(in_own_thread, mentioned, is_reply_to_bot, is_bot, agent_
|
|
|
425
425
|
end
|
|
426
426
|
|
|
427
427
|
if other_bot_mentioned
|
|
428
|
-
agent_display =
|
|
428
|
+
agent_display = agent_display_name(agent_key) || agent_key.capitalize
|
|
429
429
|
LOG.info "[Discord:#{agent_display}] Standing down in own thread — human is directing message to another agent"
|
|
430
430
|
end
|
|
431
431
|
|
|
@@ -435,7 +435,7 @@ end
|
|
|
435
435
|
def download_attachments(message, message_id, agent_key)
|
|
436
436
|
attachments = message["attachments"] || []
|
|
437
437
|
paths = []
|
|
438
|
-
agent_display =
|
|
438
|
+
agent_display = agent_display_name(agent_key) || agent_key.capitalize
|
|
439
439
|
|
|
440
440
|
attachments.each do |att|
|
|
441
441
|
url = att["url"]
|
|
@@ -645,7 +645,7 @@ def manage_discord_worktree(agent_key:, agent_name:, channel_id:, message_id:, i
|
|
|
645
645
|
end
|
|
646
646
|
|
|
647
647
|
def pre_create_discord_thread(agent_key, agent_name, channel_id, message_id, clean_content, bot_token)
|
|
648
|
-
display_name =
|
|
648
|
+
display_name = agent_display_name(agent_key)
|
|
649
649
|
thread = create_discord_thread(channel_id, message_id, name: "#{display_name}: #{clean_content[0..80]}", token: bot_token)
|
|
650
650
|
|
|
651
651
|
unless thread && thread["id"]
|
|
@@ -27,7 +27,7 @@ def handle_discord_reaction(reaction_data, agent_key, bot_token, bot_user_id)
|
|
|
27
27
|
emoji = reaction_data["emoji"]
|
|
28
28
|
emoji_name = emoji["name"]
|
|
29
29
|
|
|
30
|
-
agent_name =
|
|
30
|
+
agent_name = agent_display_name(agent_key) || agent_key.capitalize
|
|
31
31
|
|
|
32
32
|
# Ignore reactions from bots (including self)
|
|
33
33
|
return if user_id == bot_user_id
|
|
@@ -5,14 +5,9 @@
|
|
|
5
5
|
# Fallback column ID for backwards compatibility when no board config exists
|
|
6
6
|
DEFAULT_UAT_COLUMN_ID = "03fsmglsr6az06ppyotawsti8"
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
end
|
|
12
|
-
|
|
13
|
-
# Find a Fizzy card by matching the PR's head branch to a branch in the card map.
|
|
14
|
-
def find_card_by_branch(branch)
|
|
15
|
-
map = load_card_map
|
|
8
|
+
# Find a work item by matching the PR's head branch to a branch in the card map.
|
|
9
|
+
def find_work_item_by_branch(branch)
|
|
10
|
+
map = load_work_item_map
|
|
16
11
|
map.each do |internal_id, info|
|
|
17
12
|
next unless info["branch"] == branch
|
|
18
13
|
|
|
@@ -21,14 +16,14 @@ def find_card_by_branch(branch)
|
|
|
21
16
|
nil
|
|
22
17
|
end
|
|
23
18
|
|
|
24
|
-
# Track a newly opened PR in the
|
|
25
|
-
def
|
|
19
|
+
# Track a newly opened PR in the work item map by matching its branch.
|
|
20
|
+
def track_pr_in_work_items(payload)
|
|
26
21
|
pr = payload["pull_request"]
|
|
27
22
|
branch = pr.dig("head", "ref")
|
|
28
23
|
pr_number = pr["number"]
|
|
29
24
|
pr_url = pr["html_url"]
|
|
30
25
|
|
|
31
|
-
result =
|
|
26
|
+
result = find_work_item_by_branch(branch)
|
|
32
27
|
unless result
|
|
33
28
|
LOG.info "[PR Track] No card found for branch #{branch}"
|
|
34
29
|
return
|
|
@@ -41,9 +36,9 @@ def track_pr_in_card_map(payload)
|
|
|
41
36
|
prs << { "number" => pr_number, "url" => pr_url }
|
|
42
37
|
card_info["prs"] = prs
|
|
43
38
|
|
|
44
|
-
map =
|
|
39
|
+
map = load_work_item_map
|
|
45
40
|
map[internal_id] = card_info
|
|
46
|
-
|
|
41
|
+
save_work_item_map(map)
|
|
47
42
|
LOG.info "[PR Track] Tracked PR ##{pr_number} on card ##{card_info["number"]} (branch: #{branch})"
|
|
48
43
|
end
|
|
49
44
|
|
|
@@ -57,15 +52,6 @@ rescue StandardError => e
|
|
|
57
52
|
end
|
|
58
53
|
|
|
59
54
|
# Check if a PR link is already present in the card's comments.
|
|
60
|
-
def pr_link_already_commented?(card_number, pr_url, chdir:, env: default_fizzy_env)
|
|
61
|
-
output = run_cmd("fizzy", "comment", "list", "--card", card_number.to_s, chdir: chdir, env: env)
|
|
62
|
-
data = JSON.parse(output)
|
|
63
|
-
comments = data["data"] || []
|
|
64
|
-
comments.any? { |c| (c.dig("body", "plain_text") || "").include?(pr_url) }
|
|
65
|
-
rescue StandardError => e
|
|
66
|
-
LOG.warn "Could not check existing comments for card ##{card_number}: #{e.message}"
|
|
67
|
-
false
|
|
68
|
-
end
|
|
69
55
|
|
|
70
56
|
def handle_github_pr_merged(payload)
|
|
71
57
|
pr = payload["pull_request"]
|
|
@@ -90,9 +76,9 @@ def handle_github_pr_merged(payload)
|
|
|
90
76
|
project_key, project_config = project_result
|
|
91
77
|
repo_path = project_config["repo_path"]
|
|
92
78
|
|
|
93
|
-
result =
|
|
79
|
+
result = find_work_item_by_branch(branch)
|
|
94
80
|
unless result
|
|
95
|
-
LOG.info "No
|
|
81
|
+
LOG.info "No card found for branch #{branch}"
|
|
96
82
|
return [200, { status: "ignored", reason: "no matching card" }.to_json]
|
|
97
83
|
end
|
|
98
84
|
|
|
@@ -113,38 +99,21 @@ rescue StandardError => e
|
|
|
113
99
|
end
|
|
114
100
|
|
|
115
101
|
def process_merged_pr(card_info, card_number, branch, pull_request, pr_url, pr_title, project_key, project_config, repo_path)
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
card_title = card_info["title"] || pr_title
|
|
132
|
-
dispatch_uat_agent(card_number, card_title, pull_request["number"].to_s, agent_name, project_key, project_config, repo_path)
|
|
133
|
-
end
|
|
134
|
-
|
|
135
|
-
def dispatch_uat_agent(card_number, card_title, pr_number, agent_name, project_key, project_config, repo_path)
|
|
136
|
-
prompt = render_prompt(PROMPT_GITHUB_UAT,
|
|
137
|
-
{ "CARD_NUMBER" => card_number, "CARD_TITLE" => card_title, "PR_NUMBER" => pr_number },
|
|
138
|
-
brain_context: build_brain_context(agent_name: agent_name, card_number: card_number,
|
|
139
|
-
card_title: card_title, project_key: project_key),
|
|
140
|
-
agent_name: agent_name, channel: :fizzy,
|
|
141
|
-
board_key: board_key_for_project(project_config))
|
|
142
|
-
|
|
143
|
-
pid, log_file = run_agent(prompt, project_config: project_config, chdir: repo_path,
|
|
144
|
-
log_name: "uat-#{card_number}", agent_name: agent_name,
|
|
145
|
-
source: :fizzy, source_context: { card_number: card_number }, skip_column_move: true)
|
|
146
|
-
register_session("card-#{card_number}", pid, log_file: log_file, agent_name: agent_name)
|
|
147
|
-
LOG.info "Dispatched #{agent_name} for UAT testing steps on card ##{card_number}"
|
|
102
|
+
mark_work_item_merged(card_number)
|
|
103
|
+
cleanup_work_item_worktrees(card_number, repo_path: repo_path, primary_worktree: card_info["worktree"], primary_branch: branch)
|
|
104
|
+
|
|
105
|
+
# Emit hook — plugins handle their own post-merge actions
|
|
106
|
+
# (e.g., card plugin posts PR link, moves card column, dispatches UAT agent)
|
|
107
|
+
Brainiac.emit(:pr_merged,
|
|
108
|
+
card_number: card_number,
|
|
109
|
+
card_info: card_info,
|
|
110
|
+
branch: branch,
|
|
111
|
+
pull_request: pull_request,
|
|
112
|
+
pr_url: pr_url,
|
|
113
|
+
pr_title: pr_title,
|
|
114
|
+
project_key: project_key,
|
|
115
|
+
project_config: project_config,
|
|
116
|
+
repo_path: repo_path)
|
|
148
117
|
end
|
|
149
118
|
|
|
150
119
|
def handle_github_issue_comment(payload)
|
|
@@ -173,9 +142,9 @@ def handle_github_issue_comment(payload)
|
|
|
173
142
|
chdir: project_config["repo_path"])
|
|
174
143
|
branch = JSON.parse(pr_data)["branch"]
|
|
175
144
|
|
|
176
|
-
result =
|
|
145
|
+
result = find_work_item_by_branch(branch)
|
|
177
146
|
unless result
|
|
178
|
-
LOG.info "No
|
|
147
|
+
LOG.info "No card found for PR ##{pr_number} (branch: #{branch})"
|
|
179
148
|
return [200, { status: "ignored", reason: "no matching card" }.to_json]
|
|
180
149
|
end
|
|
181
150
|
|
|
@@ -265,54 +234,18 @@ rescue StandardError => e
|
|
|
265
234
|
end
|
|
266
235
|
|
|
267
236
|
def close_uat_cards_after_deploy(project_key, project_config)
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
card_list = JSON.parse(output)["data"] || []
|
|
272
|
-
|
|
273
|
-
if card_list.empty?
|
|
274
|
-
LOG.info "No cards in UAT column — nothing to close"
|
|
275
|
-
return [200, { status: "processed", action: "no_uat_cards", project: project_key }.to_json]
|
|
276
|
-
end
|
|
277
|
-
|
|
278
|
-
closed_cards = close_and_cleanup_uat_cards(card_list, repo_path)
|
|
279
|
-
send_deploy_notification(project_key, closed_cards) if closed_cards.any?
|
|
280
|
-
|
|
281
|
-
LOG.info "Prod deploy complete — closed #{closed_cards.size} UAT cards: #{closed_cards.map { |c| c[:number] }.join(", ")}"
|
|
282
|
-
[200, { status: "processed", action: "prod_deploy_closed_uat",
|
|
283
|
-
closed_cards: closed_cards.map { |c| c[:number] }, project: project_key }.to_json]
|
|
284
|
-
end
|
|
285
|
-
|
|
286
|
-
def close_and_cleanup_uat_cards(card_list, repo_path)
|
|
287
|
-
closed_cards = []
|
|
288
|
-
map = load_card_map
|
|
289
|
-
|
|
290
|
-
card_list.each do |card|
|
|
291
|
-
card_number = card["number"]
|
|
292
|
-
next unless card_number
|
|
293
|
-
|
|
294
|
-
map_entry = map.values.find { |info| info["number"] == card_number }
|
|
295
|
-
agent_name = map_entry["agent"] if map_entry
|
|
296
|
-
env = agent_name ? fizzy_env_for(agent_name) : default_fizzy_env
|
|
297
|
-
|
|
298
|
-
run_cmd("fizzy", "comment", "create", "--card", card_number.to_s,
|
|
299
|
-
"--body", "<p>✅ Deployed to production. Closing card.</p>", chdir: repo_path, env: env)
|
|
300
|
-
run_cmd("fizzy", "card", "close", card_number.to_s, chdir: repo_path, env: env)
|
|
301
|
-
|
|
302
|
-
cleanup_card_worktrees(card_number, repo_path: repo_path,
|
|
303
|
-
primary_worktree: map_entry&.dig("worktree"), primary_branch: map_entry&.dig("branch"))
|
|
304
|
-
|
|
305
|
-
if map_entry
|
|
306
|
-
internal_id = map.key(map_entry)
|
|
307
|
-
map.delete(internal_id)
|
|
308
|
-
end
|
|
237
|
+
# Emit hook — work item plugins handle closing cards after production deploy
|
|
238
|
+
results = Brainiac.emit(:production_deployed, project_key: project_key, project_config: project_config)
|
|
239
|
+
closed_cards = results.flatten.compact
|
|
309
240
|
|
|
310
|
-
|
|
311
|
-
|
|
241
|
+
if closed_cards.any?
|
|
242
|
+
send_deploy_notification(project_key, closed_cards)
|
|
243
|
+
LOG.info "Prod deploy complete — closed #{closed_cards.size} cards"
|
|
244
|
+
else
|
|
245
|
+
LOG.info "Prod deploy processed — no cards closed (plugin may not be installed)"
|
|
312
246
|
end
|
|
313
247
|
|
|
314
|
-
|
|
315
|
-
closed_cards
|
|
248
|
+
[200, { status: "processed", action: "prod_deploy", closed_cards: closed_cards.map { |c| c[:number] }, project: project_key }.to_json]
|
|
316
249
|
end
|
|
317
250
|
|
|
318
251
|
def send_deploy_notification(project_key, closed_cards)
|
|
@@ -385,7 +318,7 @@ def handle_github_pr_review_submitted(payload)
|
|
|
385
318
|
project_key, project_config = project_result
|
|
386
319
|
repo_path = project_config["repo_path"]
|
|
387
320
|
|
|
388
|
-
result =
|
|
321
|
+
result = find_work_item_by_branch(branch)
|
|
389
322
|
return [200, { status: "ignored", reason: "no matching card" }.to_json] unless result
|
|
390
323
|
|
|
391
324
|
internal_id, card_info = result
|
|
@@ -417,13 +350,9 @@ def dispatch_pr_review(card_number, card_key, card_info, pr_number, review, revi
|
|
|
417
350
|
end
|
|
418
351
|
|
|
419
352
|
agent_name = agent_name_for(project_config)
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
chdir: repo_path, env: fizzy_env_for(agent_name))
|
|
424
|
-
rescue StandardError => e
|
|
425
|
-
LOG.warn "Could not post status update to card ##{card_number}: #{e.message}"
|
|
426
|
-
end
|
|
353
|
+
# Notify work item system that a review was received (plugin posts status comment)
|
|
354
|
+
Brainiac.emit(:pr_review_received, card_number: card_number, reviewer: reviewer,
|
|
355
|
+
agent_name: agent_name, project_config: project_config, repo_path: repo_path)
|
|
427
356
|
|
|
428
357
|
review_context = build_review_context(reviewer, review, pr_number, repo_name)
|
|
429
358
|
worktree = card_info["worktree"]
|
|
@@ -462,7 +391,7 @@ def handle_github_pr_synchronized(payload)
|
|
|
462
391
|
pr = payload["pull_request"]
|
|
463
392
|
branch = pr.dig("head", "ref")
|
|
464
393
|
|
|
465
|
-
result =
|
|
394
|
+
result = find_work_item_by_branch(branch)
|
|
466
395
|
return [200, { status: "ignored", reason: "no matching card" }.to_json] unless result
|
|
467
396
|
|
|
468
397
|
_internal_id, card_info = result
|
|
@@ -471,65 +400,16 @@ def handle_github_pr_synchronized(payload)
|
|
|
471
400
|
|
|
472
401
|
return [200, { status: "ignored", reason: "no worktree" }.to_json] unless worktree && File.directory?(worktree)
|
|
473
402
|
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
return [200, { status: "ignored", reason: "card not deployed" }.to_json] unless env_key
|
|
403
|
+
# Emit hook — deployment plugin handles sync-deploy logic
|
|
404
|
+
results = Brainiac.emit(:pr_synchronized, card_number: card_number, card_info: card_info,
|
|
405
|
+
worktree: worktree, pull_request: pr, branch: branch)
|
|
479
406
|
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
touch_deploy_cooldown(env_key)
|
|
486
|
-
|
|
487
|
-
system("git", "pull", "--ff-only", chdir: worktree)
|
|
488
|
-
deploy_script = File.join(worktree, "scripts", "deploy.sh")
|
|
489
|
-
return [200, { status: "ignored", reason: "no deploy script" }.to_json] unless File.exist?(deploy_script)
|
|
490
|
-
|
|
491
|
-
LOG.info "[PR Sync] Auto-deploying card ##{card_number} to #{env_key} (PR updated)"
|
|
492
|
-
mark_deploying(env_key, worktree_path: worktree)
|
|
493
|
-
run_pr_sync_deploy(env_key, card_number, worktree, config)
|
|
494
|
-
|
|
495
|
-
[200, { status: "processed", action: "pr_sync_deploy", card: card_number, env: env_key }.to_json]
|
|
407
|
+
if results.any?
|
|
408
|
+
[200, { status: "processed", action: "pr_sync", card: card_number }.to_json]
|
|
409
|
+
else
|
|
410
|
+
[200, { status: "ignored", reason: "no deployment plugin" }.to_json]
|
|
411
|
+
end
|
|
496
412
|
rescue StandardError => e
|
|
497
413
|
LOG.error "[PR Sync] Error: #{e.message}"
|
|
498
414
|
[500, { error: e.message }.to_json]
|
|
499
415
|
end
|
|
500
|
-
|
|
501
|
-
def run_pr_sync_deploy(env_key, card_number, worktree, config)
|
|
502
|
-
Thread.new do
|
|
503
|
-
deploy_env = {}
|
|
504
|
-
aws_profile = config.dig(env_key, "aws_profile")
|
|
505
|
-
deploy_env["AWS_PROFILE"] = aws_profile if aws_profile
|
|
506
|
-
deploy_script = File.join(worktree, "scripts", "deploy.sh")
|
|
507
|
-
|
|
508
|
-
stdout, stderr, status = Open3.capture3(deploy_env, deploy_script, env_key, chdir: worktree)
|
|
509
|
-
|
|
510
|
-
if status.success?
|
|
511
|
-
deploy_to_environment(env_key, worktree_path: worktree, deployed_by: "pr-sync")
|
|
512
|
-
LOG.info "[PR Sync] Deploy to #{env_key} succeeded for card ##{card_number}"
|
|
513
|
-
elsif terraform_lock_error?(stdout, stderr)
|
|
514
|
-
retry_pr_sync_deploy(deploy_env, deploy_script, env_key, card_number, worktree)
|
|
515
|
-
else
|
|
516
|
-
record_deploy_failure(env_key, worktree_path: worktree, stdout: stdout, stderr: stderr)
|
|
517
|
-
LOG.error "[PR Sync] Deploy to #{env_key} failed for card ##{card_number}"
|
|
518
|
-
end
|
|
519
|
-
end
|
|
520
|
-
end
|
|
521
|
-
|
|
522
|
-
def retry_pr_sync_deploy(deploy_env, deploy_script, env_key, card_number, worktree)
|
|
523
|
-
infra_dir = File.join(worktree, "infrastructure/#{env_key}")
|
|
524
|
-
FileUtils.rm_f(File.join(infra_dir, ".terraform.lock.hcl"))
|
|
525
|
-
Open3.capture3("terraform", "init", "-upgrade", chdir: infra_dir)
|
|
526
|
-
stdout, stderr, status = Open3.capture3(deploy_env, deploy_script, env_key, chdir: worktree)
|
|
527
|
-
|
|
528
|
-
if status.success?
|
|
529
|
-
deploy_to_environment(env_key, worktree_path: worktree, deployed_by: "pr-sync")
|
|
530
|
-
LOG.info "[PR Sync] Deploy to #{env_key} succeeded (after terraform lock fix) for card ##{card_number}"
|
|
531
|
-
else
|
|
532
|
-
record_deploy_failure(env_key, worktree_path: worktree, stdout: stdout, stderr: stderr)
|
|
533
|
-
LOG.error "[PR Sync] Deploy to #{env_key} failed (after retry) for card ##{card_number}"
|
|
534
|
-
end
|
|
535
|
-
end
|
|
@@ -156,30 +156,17 @@ def create_or_reuse_worktree(repo_path:, branch:, base_ref: nil, worktree_path:
|
|
|
156
156
|
worktree_path
|
|
157
157
|
end
|
|
158
158
|
|
|
159
|
-
#
|
|
160
|
-
def find_worktree_for_card(card_number, repo_path:)
|
|
161
|
-
return nil unless card_number
|
|
162
|
-
|
|
163
|
-
repo_dir = File.dirname(repo_path)
|
|
164
|
-
repo_base = File.basename(repo_path)
|
|
165
|
-
candidates = Dir.glob(File.join(repo_dir, "#{repo_base}--fizzy-#{card_number}-*")).select { |d| File.directory?(d) }
|
|
166
|
-
return nil if candidates.empty?
|
|
167
|
-
|
|
168
|
-
worktree = candidates.first
|
|
169
|
-
branch = File.basename(worktree).sub("#{repo_base}--", "")
|
|
170
|
-
{ worktree: worktree, branch: branch }
|
|
171
|
-
end
|
|
172
|
-
|
|
173
|
-
# Clean up all worktrees associated with a card (primary + cross-agent review).
|
|
159
|
+
# Clean up all worktrees associated with a work item (primary + cross-agent review).
|
|
174
160
|
# Safe: skips worktrees with uncommitted changes.
|
|
175
|
-
def
|
|
161
|
+
def cleanup_work_item_worktrees(card_number, repo_path:, primary_worktree: nil, primary_branch: nil)
|
|
176
162
|
return unless card_number
|
|
177
163
|
|
|
178
164
|
repo_dir = File.dirname(repo_path)
|
|
179
165
|
repo_base = File.basename(repo_path)
|
|
180
166
|
cleaned = 0
|
|
181
167
|
|
|
182
|
-
|
|
168
|
+
# Find worktrees that contain the work item number in their name (any naming convention)
|
|
169
|
+
candidates = Dir.glob(File.join(repo_dir, "#{repo_base}--*#{card_number}*")).select { |d| File.directory?(d) }
|
|
183
170
|
candidates << primary_worktree if primary_worktree && File.directory?(primary_worktree) && !candidates.include?(primary_worktree)
|
|
184
171
|
|
|
185
172
|
candidates.uniq.each do |wt_path|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
# Shared inline tag parsing for handler messages.
|
|
4
4
|
#
|
|
5
|
-
#
|
|
5
|
+
# Messages from any channel can contain inline tags like:
|
|
6
6
|
# [project:my-project], [opus], [effort:high], [cli:grok], [chat], [plan]
|
|
7
7
|
#
|
|
8
8
|
# This module provides a single parser that extracts all tags and returns
|