brainiac-fizzy 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 +111 -0
- data/lib/brainiac/plugins/fizzy/config.rb +104 -0
- data/lib/brainiac/plugins/fizzy/delegators.rb +106 -0
- data/lib/brainiac/plugins/fizzy/handlers/assignment.rb +128 -0
- data/lib/brainiac/plugins/fizzy/handlers/card_index.rb +389 -0
- data/lib/brainiac/plugins/fizzy/handlers/comments.rb +749 -0
- data/lib/brainiac/plugins/fizzy/handlers/dedup.rb +74 -0
- data/lib/brainiac/plugins/fizzy/handlers/deploy.rb +152 -0
- data/lib/brainiac/plugins/fizzy/handlers/deployments.rb +260 -0
- data/lib/brainiac/plugins/fizzy/helpers.rb +165 -0
- data/lib/brainiac/plugins/fizzy/hooks.rb +371 -0
- data/lib/brainiac/plugins/fizzy/planning.rb +73 -0
- data/lib/brainiac/plugins/fizzy/prompts.rb +119 -0
- data/lib/brainiac/plugins/fizzy/version.rb +9 -0
- data/lib/brainiac/plugins/fizzy.rb +212 -0
- data/lib/brainiac_fizzy.rb +4 -0
- metadata +128 -0
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Card duplicate detection (card_published / card_triaged).
|
|
4
|
+
#
|
|
5
|
+
# When a new card is created, checks for similar existing cards using
|
|
6
|
+
# trigram and semantic similarity. Posts a warning comment if duplicates found.
|
|
7
|
+
|
|
8
|
+
def handle_card_published(payload)
|
|
9
|
+
eventable = payload["eventable"] || {}
|
|
10
|
+
card_number = eventable["number"]
|
|
11
|
+
title = eventable["title"] || ""
|
|
12
|
+
creator_name = payload.dig("creator", "name")
|
|
13
|
+
creator_id = payload.dig("creator", "id")
|
|
14
|
+
tags = eventable["tags"] || []
|
|
15
|
+
|
|
16
|
+
# Creator-based routing: only the machine whose local human created the card
|
|
17
|
+
# handles dedup. Requires `"local": true` on the human in fizzy.json authorized_users.
|
|
18
|
+
local_humans = FIZZY_CONFIG.fetch("authorized_users", []).select { |u| u["human"] && u["local"] }
|
|
19
|
+
if local_humans.empty?
|
|
20
|
+
LOG.info "[CardIndex] No local humans configured — skipping dedup, indexing only"
|
|
21
|
+
return index_card_only(card_number, title, creator_name, creator_id, tags)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
unless local_humans.any? { |u| u["id"] == creator_id }
|
|
25
|
+
LOG.info "[CardIndex] Ignoring card ##{card_number} — creator '#{creator_name}' is not a local human"
|
|
26
|
+
return index_card_only(card_number, title, creator_name, creator_id, tags)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Check for duplicates before indexing
|
|
30
|
+
similar = CARD_INDEX.find_similar_cards(title, exclude_number: card_number, tags: tags) if card_number
|
|
31
|
+
index_card_only(card_number, title, creator_name, creator_id, tags, skip_response: true)
|
|
32
|
+
|
|
33
|
+
if similar&.any?
|
|
34
|
+
post_duplicate_warning(card_number, title, tags, similar)
|
|
35
|
+
[200, { status: "duplicate_detected", card: card_number,
|
|
36
|
+
similar: similar.map { |s| { number: s[:number], score: s[:score].round(2) } } }.to_json]
|
|
37
|
+
else
|
|
38
|
+
LOG.info "[CardIndex] Card ##{card_number} '#{title}' indexed, no duplicates found"
|
|
39
|
+
[200, { status: "indexed", card: card_number }.to_json]
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def index_card_only(card_number, title, creator_name, creator_id, tags, skip_response: false)
|
|
44
|
+
CARD_INDEX.index_card(number: card_number, title: title, creator_name: creator_name, creator_id: creator_id, tags: tags) if card_number
|
|
45
|
+
CARD_INDEX.save
|
|
46
|
+
CARD_INDEX.schedule_qmd_reindex
|
|
47
|
+
[200, { status: "indexed", card: card_number }.to_json] unless skip_response
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def post_duplicate_warning(card_number, title, tags, similar)
|
|
51
|
+
best = similar.first
|
|
52
|
+
LOG.info "[CardIndex] Potential duplicate: ##{card_number} '#{title}' ≈ " \
|
|
53
|
+
"##{best[:number]} '#{best[:title]}' (score: #{best[:score].round(2)})"
|
|
54
|
+
|
|
55
|
+
project_result = identify_project_by_tags(tags)
|
|
56
|
+
return unless project_result
|
|
57
|
+
|
|
58
|
+
_project_key, project_config = project_result
|
|
59
|
+
repo_path = project_config["repo_path"]
|
|
60
|
+
|
|
61
|
+
Thread.new do
|
|
62
|
+
method_label = { trigram: "📝", semantic: "🧠", both: "📝🧠" }
|
|
63
|
+
dupes = similar.map do |s|
|
|
64
|
+
icon = method_label[s[:method]] || "📝"
|
|
65
|
+
"##{s[:number]} \"#{s[:title]}\" (#{(s[:score] * 100).round}% #{icon})"
|
|
66
|
+
end.join("\n- ")
|
|
67
|
+
body = "⚠️ **Possible duplicate detected:**\n- #{dupes}\n\n_📝 = text similarity, 🧠 = semantic similarity_"
|
|
68
|
+
run_cmd("fizzy", "comment", "create", "--card", card_number.to_s, "--body", body,
|
|
69
|
+
chdir: repo_path, env: default_fizzy_env)
|
|
70
|
+
LOG.info "[CardIndex] Posted duplicate warning on card ##{card_number}"
|
|
71
|
+
rescue StandardError => e
|
|
72
|
+
LOG.warn "[CardIndex] Failed to post duplicate warning: #{e.message}"
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Fizzy deploy comment handler.
|
|
4
|
+
#
|
|
5
|
+
# When a comment is just "dev02" (or any dev\d+), deploy the card's
|
|
6
|
+
# worktree to that environment. No agent dispatch — reactions only.
|
|
7
|
+
|
|
8
|
+
def handle_deploy_comment(eventable, env_key, card_internal_id)
|
|
9
|
+
comment_id = eventable["id"]
|
|
10
|
+
card_info = load_card_map[card_internal_id]
|
|
11
|
+
|
|
12
|
+
# Validate environment exists
|
|
13
|
+
deploy_config = DEPLOYMENTS_CONFIG["environments"] || {}
|
|
14
|
+
unless deploy_config.key?(env_key)
|
|
15
|
+
LOG.warn "[Deploy] Unknown environment: #{env_key}"
|
|
16
|
+
return [200, { status: "ignored", reason: "unknown environment" }.to_json]
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Check environment ownership
|
|
20
|
+
env_owner = deploy_config[env_key]["owner"]
|
|
21
|
+
unless env_owner && env_owner.downcase == AI_AGENT_NAME.downcase
|
|
22
|
+
LOG.info "[Deploy] Skipping #{env_key} — owner is #{env_owner.inspect}, this machine is #{AI_AGENT_NAME}"
|
|
23
|
+
return [200, { status: "ignored", reason: env_owner ? "owned by #{env_owner}" : "no owner configured" }.to_json]
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
worktree = card_info&.dig("worktree")
|
|
27
|
+
card_number = card_info&.dig("number")
|
|
28
|
+
|
|
29
|
+
# If worktree doesn't exist locally, try to clone the branch from origin
|
|
30
|
+
if worktree.nil? || !File.directory?(worktree)
|
|
31
|
+
result = clone_branch_for_deploy(eventable, card_internal_id, card_info)
|
|
32
|
+
unless result
|
|
33
|
+
LOG.warn "[Deploy] Could not resolve or clone branch for card #{card_internal_id}"
|
|
34
|
+
return [200, { status: "ignored", reason: "no worktree and could not clone branch" }.to_json]
|
|
35
|
+
end
|
|
36
|
+
worktree = result[:worktree]
|
|
37
|
+
card_number = result[:card_number]
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
deploy_script = File.join(worktree, "scripts", "deploy.sh")
|
|
41
|
+
unless File.exist?(deploy_script)
|
|
42
|
+
LOG.warn "[Deploy] No deploy script at #{deploy_script}"
|
|
43
|
+
return [200, { status: "ignored", reason: "no deploy script" }.to_json]
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
LOG.info "[Deploy] Deploying card ##{card_number} worktree to #{env_key}"
|
|
47
|
+
mark_deploying(env_key, worktree_path: worktree)
|
|
48
|
+
|
|
49
|
+
Thread.new do
|
|
50
|
+
react_to_deploy(card_number, comment_id, worktree, "🚀")
|
|
51
|
+
run_deploy(env_key, card_number, comment_id, worktree)
|
|
52
|
+
rescue StandardError => e
|
|
53
|
+
LOG.error "[Deploy] Error deploying card ##{card_number} to #{env_key}: #{e.message}"
|
|
54
|
+
react_to_deploy(card_number, comment_id, worktree, "❌")
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
[200, { status: "deploying", card: card_number, env: env_key }.to_json]
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def react_to_deploy(card_number, comment_id, worktree, emoji)
|
|
61
|
+
run_cmd("fizzy", "reaction", "create", "--card", card_number.to_s,
|
|
62
|
+
"--comment", comment_id.to_s, "--content", emoji,
|
|
63
|
+
chdir: worktree, env: default_fizzy_env)
|
|
64
|
+
rescue StandardError => e
|
|
65
|
+
LOG.warn "[Deploy] Could not add reaction #{emoji}: #{e.message}"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def run_deploy(env_key, card_number, comment_id, worktree)
|
|
69
|
+
deploy_env = {}
|
|
70
|
+
aws_profile = DEPLOYMENTS_CONFIG.dig("environments", env_key, "aws_profile")
|
|
71
|
+
deploy_env["AWS_PROFILE"] = aws_profile if aws_profile
|
|
72
|
+
|
|
73
|
+
stdout, stderr, status = Open3.capture3(deploy_env, "./scripts/deploy.sh", env_key, chdir: worktree)
|
|
74
|
+
|
|
75
|
+
if !status.success? && terraform_lock_error?(stdout, stderr)
|
|
76
|
+
stdout, stderr, status = retry_deploy_with_init(deploy_env, env_key, card_number, worktree)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
if status.success?
|
|
80
|
+
LOG.info "[Deploy] Successfully deployed card ##{card_number} to #{env_key}"
|
|
81
|
+
react_to_deploy(card_number, comment_id, worktree, "✅")
|
|
82
|
+
deploy_to_environment(env_key, worktree_path: worktree, deployed_by: "fizzy-comment")
|
|
83
|
+
else
|
|
84
|
+
LOG.error "[Deploy] Failed deploying card ##{card_number} to #{env_key}: #{stderr}"
|
|
85
|
+
react_to_deploy(card_number, comment_id, worktree, "❌")
|
|
86
|
+
record_deploy_failure(env_key, worktree_path: worktree, stdout: stdout, stderr: stderr)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def retry_deploy_with_init(deploy_env, env_key, card_number, worktree)
|
|
91
|
+
LOG.info "[Deploy] Terraform lock file mismatch for card ##{card_number} — retrying with init -upgrade"
|
|
92
|
+
infra_dir = File.join(worktree, "infrastructure", env_key)
|
|
93
|
+
lock_file = File.join(infra_dir, ".terraform.lock.hcl")
|
|
94
|
+
FileUtils.rm_f(lock_file)
|
|
95
|
+
Open3.capture3(deploy_env, "terraform", "init", "-upgrade", chdir: infra_dir) if File.directory?(infra_dir)
|
|
96
|
+
Open3.capture3(deploy_env, "./scripts/deploy.sh", env_key, chdir: worktree)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Clone a remote branch locally for deploy when the worktree doesn't exist on this machine.
|
|
100
|
+
# Returns { worktree:, card_number: } on success, nil on failure.
|
|
101
|
+
def clone_branch_for_deploy(eventable, card_internal_id, card_info)
|
|
102
|
+
card_tags = eventable.dig("card", "tags") || []
|
|
103
|
+
project_result = identify_project_by_tags(card_tags)
|
|
104
|
+
unless project_result
|
|
105
|
+
LOG.warn "[Deploy] Cannot identify project for card #{card_internal_id}"
|
|
106
|
+
return nil
|
|
107
|
+
end
|
|
108
|
+
project_key, project_config = project_result
|
|
109
|
+
repo_path = project_config["repo_path"]
|
|
110
|
+
|
|
111
|
+
card_number = card_info&.dig("number")
|
|
112
|
+
card_number ||= resolve_card_number(card_internal_id, repo_path: repo_path)
|
|
113
|
+
unless card_number
|
|
114
|
+
LOG.warn "[Deploy] Cannot resolve card number for #{card_internal_id}"
|
|
115
|
+
return nil
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
debounced_repo_fetch(repo_path)
|
|
119
|
+
branches = run_cmd("git", "branch", "-r", "--list", "origin/fizzy-#{card_number}-*", chdir: repo_path).strip
|
|
120
|
+
branch = branches.lines.map(&:strip).first&.sub("origin/", "")
|
|
121
|
+
unless branch
|
|
122
|
+
LOG.warn "[Deploy] No remote branch matching fizzy-#{card_number}-* found"
|
|
123
|
+
return nil
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
worktree_path = File.join(File.dirname(repo_path), "#{File.basename(repo_path)}--#{branch}")
|
|
127
|
+
|
|
128
|
+
unless File.directory?(worktree_path)
|
|
129
|
+
branch_exists_locally = system("git", "rev-parse", "--verify", branch, chdir: repo_path, out: File::NULL, err: File::NULL)
|
|
130
|
+
if branch_exists_locally
|
|
131
|
+
run_cmd("git", "worktree", "add", worktree_path, branch, chdir: repo_path)
|
|
132
|
+
else
|
|
133
|
+
run_cmd("git", "worktree", "add", "--track", "-b", branch, worktree_path, "origin/#{branch}", chdir: repo_path)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
trust_version_manager(worktree_path, chdir: worktree_path)
|
|
137
|
+
apply_worktree_includes(repo_path, worktree_path)
|
|
138
|
+
run_project_hook(repo_path, "worktree-setup", extra_env: { "WORKTREE_PATH" => worktree_path })
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Update card map
|
|
142
|
+
map = load_card_map
|
|
143
|
+
map[card_internal_id] ||= {}
|
|
144
|
+
map[card_internal_id].merge!("number" => card_number, "branch" => branch, "worktree" => worktree_path, "project" => project_key)
|
|
145
|
+
save_card_map(map)
|
|
146
|
+
|
|
147
|
+
LOG.info "[Deploy] Cloned branch #{branch} into worktree #{worktree_path} for card ##{card_number}"
|
|
148
|
+
{ worktree: worktree_path, card_number: card_number }
|
|
149
|
+
rescue StandardError => e
|
|
150
|
+
LOG.error "[Deploy] Failed to clone branch for card #{card_internal_id}: #{e.message}"
|
|
151
|
+
nil
|
|
152
|
+
end
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "shellwords"
|
|
4
|
+
|
|
5
|
+
# Deployment environment tracking.
|
|
6
|
+
# Tracks which dev environments have active card deploys and which are available.
|
|
7
|
+
|
|
8
|
+
DEPLOYMENTS_CONFIG_FILE = File.join(BRAINIAC_DIR, "deployments.json")
|
|
9
|
+
DEPLOYMENT_STATE_FILE = File.join(BRAINIAC_DIR, "deployment_state.json")
|
|
10
|
+
|
|
11
|
+
def load_deployments_config
|
|
12
|
+
return {} unless File.exist?(DEPLOYMENTS_CONFIG_FILE)
|
|
13
|
+
|
|
14
|
+
JSON.parse(File.read(DEPLOYMENTS_CONFIG_FILE))
|
|
15
|
+
rescue JSON::ParserError => e
|
|
16
|
+
LOG.error "Failed to parse deployments config: #{e.message}"
|
|
17
|
+
{}
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def load_deployment_state
|
|
21
|
+
return {} unless File.exist?(DEPLOYMENT_STATE_FILE)
|
|
22
|
+
|
|
23
|
+
JSON.parse(File.read(DEPLOYMENT_STATE_FILE))
|
|
24
|
+
rescue JSON::ParserError => e
|
|
25
|
+
LOG.error "Failed to parse deployment state: #{e.message}"
|
|
26
|
+
{}
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def save_deployment_state(state)
|
|
30
|
+
File.write(DEPLOYMENT_STATE_FILE, JSON.pretty_generate(state))
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
DEPLOYMENTS_CONFIG = load_deployments_config
|
|
34
|
+
DEPLOYMENT_STATE = load_deployment_state
|
|
35
|
+
|
|
36
|
+
def reload_deployments_config!(force: false)
|
|
37
|
+
return unless file_changed?(DEPLOYMENTS_CONFIG_FILE, force: force)
|
|
38
|
+
|
|
39
|
+
DEPLOYMENTS_CONFIG.replace(load_deployments_config)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def reload_deployment_state!(force: false)
|
|
43
|
+
return unless file_changed?(DEPLOYMENT_STATE_FILE, force: force)
|
|
44
|
+
|
|
45
|
+
DEPLOYMENT_STATE.replace(load_deployment_state)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Mark an environment as actively deploying (in-progress state for waybar).
|
|
49
|
+
def mark_deploying(env_key, worktree_path:)
|
|
50
|
+
state = load_deployment_state
|
|
51
|
+
state[env_key] ||= {}
|
|
52
|
+
state[env_key]["status"] = "occupied"
|
|
53
|
+
state[env_key]["last_deploy_status"] = "deploying"
|
|
54
|
+
state[env_key]["last_deploy_at"] = Time.now.iso8601
|
|
55
|
+
save_deployment_state(state)
|
|
56
|
+
DEPLOYMENT_STATE.replace(state)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Mark an environment as occupied. Resolves card info from the card map using the worktree path.
|
|
60
|
+
def deploy_to_environment(env_key, worktree_path:, deployed_by: nil)
|
|
61
|
+
config = DEPLOYMENTS_CONFIG["environments"] || {}
|
|
62
|
+
unless config.key?(env_key)
|
|
63
|
+
LOG.warn "[Deploy] Unknown environment: #{env_key}"
|
|
64
|
+
return { error: "Unknown environment: #{env_key}" }
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
state = load_deployment_state
|
|
68
|
+
entry = { "status" => "occupied", "deployed_at" => Time.now.iso8601, "deployed_by" => deployed_by,
|
|
69
|
+
"last_deploy_status" => "success", "last_deploy_at" => Time.now.iso8601 }
|
|
70
|
+
|
|
71
|
+
# Resolve card info from card map by matching worktree path
|
|
72
|
+
map = load_card_map
|
|
73
|
+
card_entry = map.values.find { |info| info["worktree"] == worktree_path }
|
|
74
|
+
if card_entry
|
|
75
|
+
entry["card_number"] = card_entry["number"]
|
|
76
|
+
entry["card_title"] = card_entry["title"]
|
|
77
|
+
entry["branch"] = card_entry["branch"]
|
|
78
|
+
pr = (card_entry["prs"] || []).last
|
|
79
|
+
if pr
|
|
80
|
+
entry["pr_number"] = pr["number"]
|
|
81
|
+
entry["pr_url"] = pr["url"]
|
|
82
|
+
end
|
|
83
|
+
# Store card tags for URL resolution (e.g. ops-web-app → ops URL)
|
|
84
|
+
if defined?(CARD_INDEX)
|
|
85
|
+
card_idx = CARD_INDEX[card_entry["number"].to_s]
|
|
86
|
+
entry["card_tags"] = card_idx["tags"] if card_idx && card_idx["tags"]
|
|
87
|
+
end
|
|
88
|
+
else
|
|
89
|
+
# No card map match — record branch from git
|
|
90
|
+
branch = `git -C #{Shellwords.escape(worktree_path)} rev-parse --abbrev-ref HEAD 2>/dev/null`.strip
|
|
91
|
+
entry["branch"] = branch unless branch.empty?
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
commit = `git -C #{Shellwords.escape(worktree_path)} rev-parse --short HEAD 2>/dev/null`.strip
|
|
95
|
+
entry["commit"] = commit unless commit.empty?
|
|
96
|
+
|
|
97
|
+
state[env_key] = entry
|
|
98
|
+
save_deployment_state(state)
|
|
99
|
+
DEPLOYMENT_STATE.replace(state)
|
|
100
|
+
LOG.info "[Deploy] #{env_key} marked occupied — card ##{entry["card_number"] || "none"}, branch: #{entry["branch"]}"
|
|
101
|
+
entry
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
DEPLOY_LOGS_DIR = File.join(BRAINIAC_DIR, "deploy_logs")
|
|
105
|
+
|
|
106
|
+
# Record a failed deploy — saves output to a log file and updates state.
|
|
107
|
+
def record_deploy_failure(env_key, worktree_path:, stdout: "", stderr: "")
|
|
108
|
+
FileUtils.mkdir_p(DEPLOY_LOGS_DIR)
|
|
109
|
+
log_file = File.join(DEPLOY_LOGS_DIR, "#{env_key}-#{Time.now.strftime("%Y%m%d-%H%M%S")}.log")
|
|
110
|
+
File.write(log_file, "=== STDOUT ===\n#{stdout}\n\n=== STDERR ===\n#{stderr}")
|
|
111
|
+
|
|
112
|
+
state = load_deployment_state
|
|
113
|
+
state[env_key] ||= {}
|
|
114
|
+
state[env_key]["last_deploy_status"] = "failed"
|
|
115
|
+
state[env_key]["last_deploy_at"] = Time.now.iso8601
|
|
116
|
+
state[env_key]["last_deploy_log"] = log_file
|
|
117
|
+
save_deployment_state(state)
|
|
118
|
+
DEPLOYMENT_STATE.replace(state)
|
|
119
|
+
LOG.info "[Deploy] #{env_key} deploy failed — log at #{log_file}"
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Auto-deploy after agent session when [deploy] tag was present.
|
|
123
|
+
# deploy_intent is either a specific env key (e.g. "dev04"), :auto (auto-detect), or nil (no deploy).
|
|
124
|
+
def auto_deploy_after_session(deploy_intent:, card_internal_id:, card_number:, worktree_path:, agent_name:)
|
|
125
|
+
state = load_deployment_state
|
|
126
|
+
config = DEPLOYMENTS_CONFIG["environments"] || {}
|
|
127
|
+
|
|
128
|
+
env_key = resolve_deploy_environment(deploy_intent, state, card_number)
|
|
129
|
+
return unless env_key
|
|
130
|
+
|
|
131
|
+
unless config.key?(env_key)
|
|
132
|
+
LOG.warn "[Deploy] Auto-deploy skipped — unknown environment: #{env_key}"
|
|
133
|
+
return
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
env_owner = config[env_key]["owner"]
|
|
137
|
+
unless env_owner && env_owner.downcase == AI_AGENT_NAME.downcase
|
|
138
|
+
LOG.info "[Deploy] Auto-deploy skipped #{env_key} — owner is #{env_owner.inspect}, this machine is #{AI_AGENT_NAME}"
|
|
139
|
+
return
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
deploy_script = File.join(worktree_path, "scripts", "deploy.sh")
|
|
143
|
+
unless File.exist?(deploy_script)
|
|
144
|
+
LOG.warn "[Deploy] Auto-deploy skipped — no deploy script at #{deploy_script}"
|
|
145
|
+
return
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
LOG.info "[Deploy] Auto-deploying card ##{card_number} to #{env_key} (triggered by [deploy] tag)"
|
|
149
|
+
mark_deploying(env_key, worktree_path: worktree_path)
|
|
150
|
+
|
|
151
|
+
deploy_env = {}
|
|
152
|
+
aws_profile = config.dig(env_key, "aws_profile")
|
|
153
|
+
deploy_env["AWS_PROFILE"] = aws_profile if aws_profile
|
|
154
|
+
|
|
155
|
+
run_deploy(deploy_env, deploy_script, env_key, worktree_path: worktree_path, card_number: card_number, agent_name: agent_name)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Resolve which environment to deploy to from the intent.
|
|
159
|
+
def resolve_deploy_environment(deploy_intent, state, card_number)
|
|
160
|
+
if deploy_intent.is_a?(String) && !deploy_intent.empty?
|
|
161
|
+
deploy_intent
|
|
162
|
+
else
|
|
163
|
+
existing = state.find { |_k, v| v["card_number"] == card_number && v["status"] == "occupied" }&.first
|
|
164
|
+
LOG.info "[Deploy] Auto-deploy skipped — card ##{card_number} not currently deployed to any environment" unless existing
|
|
165
|
+
existing
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Execute deploy script with terraform lock retry logic.
|
|
170
|
+
def run_deploy(deploy_env, deploy_script, env_key, worktree_path:, card_number:, agent_name:)
|
|
171
|
+
stdout, stderr, status = Open3.capture3(deploy_env, deploy_script, env_key, chdir: worktree_path)
|
|
172
|
+
|
|
173
|
+
if status.success?
|
|
174
|
+
deploy_to_environment(env_key, worktree_path: worktree_path, deployed_by: "#{agent_name} [deploy]")
|
|
175
|
+
LOG.info "[Deploy] Auto-deploy to #{env_key} succeeded for card ##{card_number}"
|
|
176
|
+
elsif terraform_lock_error?(stdout, stderr)
|
|
177
|
+
retry_deploy_after_lock_fix(deploy_env, deploy_script, env_key, worktree_path: worktree_path, card_number: card_number, agent_name: agent_name)
|
|
178
|
+
else
|
|
179
|
+
record_deploy_failure(env_key, worktree_path: worktree_path, stdout: stdout, stderr: stderr)
|
|
180
|
+
LOG.error "[Deploy] Auto-deploy to #{env_key} failed for card ##{card_number}"
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Retry deploy after clearing terraform lock.
|
|
185
|
+
def retry_deploy_after_lock_fix(deploy_env, deploy_script, env_key, worktree_path:, card_number:, agent_name:)
|
|
186
|
+
lock_file = File.join(worktree_path, "infrastructure/#{env_key}/.terraform.lock.hcl")
|
|
187
|
+
FileUtils.rm_f(lock_file)
|
|
188
|
+
Open3.capture3("terraform", "init", "-upgrade", chdir: File.join(worktree_path, "infrastructure/#{env_key}"))
|
|
189
|
+
stdout2, stderr2, status2 = Open3.capture3(deploy_env, deploy_script, env_key, chdir: worktree_path)
|
|
190
|
+
if status2.success?
|
|
191
|
+
deploy_to_environment(env_key, worktree_path: worktree_path, deployed_by: "#{agent_name} [deploy]")
|
|
192
|
+
LOG.info "[Deploy] Auto-deploy to #{env_key} succeeded (after terraform lock fix) for card ##{card_number}"
|
|
193
|
+
else
|
|
194
|
+
record_deploy_failure(env_key, worktree_path: worktree_path, stdout: stdout2, stderr: stderr2)
|
|
195
|
+
LOG.error "[Deploy] Auto-deploy to #{env_key} failed (after retry) for card ##{card_number}"
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# Detect Terraform provider lock file checksum mismatch errors.
|
|
200
|
+
def terraform_lock_error?(stdout, stderr)
|
|
201
|
+
combined = "#{stdout}\n#{stderr}"
|
|
202
|
+
combined.include?("checksums previously recorded in the dependency lock file")
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# Clear all environments occupied by a given card number (called on PR merge).
|
|
206
|
+
def clear_deployment_for_card(card_number)
|
|
207
|
+
state = load_deployment_state
|
|
208
|
+
cleared = []
|
|
209
|
+
|
|
210
|
+
state.each do |env_key, info|
|
|
211
|
+
next unless info["card_number"] == card_number && info["status"] == "occupied"
|
|
212
|
+
|
|
213
|
+
state[env_key] = { "status" => "available", "cleared_at" => Time.now.iso8601, "last_card" => card_number }
|
|
214
|
+
cleared << env_key
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
if cleared.any?
|
|
218
|
+
save_deployment_state(state)
|
|
219
|
+
DEPLOYMENT_STATE.replace(state)
|
|
220
|
+
LOG.info "[Deploy] Cleared #{cleared.join(", ")} — card ##{card_number} merged"
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
cleared
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# Return environments with status "available", optionally filtered by project.
|
|
227
|
+
def available_environments(project: nil)
|
|
228
|
+
config = DEPLOYMENTS_CONFIG["environments"] || {}
|
|
229
|
+
state = load_deployment_state
|
|
230
|
+
|
|
231
|
+
config.select do |env_key, env_config|
|
|
232
|
+
next false if project && env_config["project"] != project
|
|
233
|
+
|
|
234
|
+
info = state[env_key]
|
|
235
|
+
info.nil? || info["status"] == "available"
|
|
236
|
+
end.keys
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# Full deployment status for API / waybar.
|
|
240
|
+
def deployment_status
|
|
241
|
+
config = DEPLOYMENTS_CONFIG["environments"] || {}
|
|
242
|
+
state = load_deployment_state
|
|
243
|
+
|
|
244
|
+
config.map do |env_key, env_config|
|
|
245
|
+
info = state[env_key] || { "status" => "available" }
|
|
246
|
+
url = resolve_deployment_url(env_config, info["card_tags"])
|
|
247
|
+
{ "env" => env_key, "label" => env_config["label"], "url" => url, "project" => env_config["project"] }.merge(info)
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# Resolve the correct URL for an environment based on card tags.
|
|
252
|
+
# If the card has a tag matching a key in the environment's "urls" map, use that URL.
|
|
253
|
+
# Otherwise fall back to the default "url".
|
|
254
|
+
def resolve_deployment_url(env_config, card_tags)
|
|
255
|
+
urls = env_config["urls"] || {}
|
|
256
|
+
if card_tags && urls.any?
|
|
257
|
+
card_tags.each { |tag| return urls[tag] if urls[tag] }
|
|
258
|
+
end
|
|
259
|
+
env_config["url"]
|
|
260
|
+
end
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Brainiac
|
|
4
|
+
module Plugins
|
|
5
|
+
module Fizzy
|
|
6
|
+
# Fizzy-specific helper functions.
|
|
7
|
+
# These were previously in lib/brainiac/helpers.rb in core.
|
|
8
|
+
module Helpers
|
|
9
|
+
class << self
|
|
10
|
+
# Returns true if signature is valid (or no secret configured).
|
|
11
|
+
# Returns false if signature verification fails.
|
|
12
|
+
def verify_signature!(request, payload_body, board_key: nil)
|
|
13
|
+
signature = request.env["HTTP_X_WEBHOOK_SIGNATURE"]
|
|
14
|
+
return false unless signature
|
|
15
|
+
|
|
16
|
+
secret = board_key ? Config.board_webhook_secret(board_key) : ENV.fetch("FIZZY_WEBHOOK_SECRET", nil)
|
|
17
|
+
return false unless secret
|
|
18
|
+
|
|
19
|
+
computed = OpenSSL::HMAC.hexdigest("sha256", secret, payload_body)
|
|
20
|
+
Rack::Utils.secure_compare(signature, computed)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def fizzy_token_for(agent_name)
|
|
24
|
+
agent_env_var(agent_name, "FIZZY_TOKEN")
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def fizzy_env_for(agent_name)
|
|
28
|
+
token = fizzy_token_for(agent_name) || fizzy_token_for(AI_AGENT_NAME)
|
|
29
|
+
token ? { "FIZZY_TOKEN" => token } : {}
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def default_fizzy_env
|
|
33
|
+
fizzy_env_for(AI_AGENT_NAME)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def prefetch_card_context(card_number, repo_path:, agent_name: nil)
|
|
37
|
+
env = fizzy_env_for(agent_name || AI_AGENT_NAME)
|
|
38
|
+
card_details = fetch_card_details(card_number, repo_path: repo_path, env: env)
|
|
39
|
+
card_comments = fetch_card_comments(card_number, repo_path: repo_path, env: env)
|
|
40
|
+
|
|
41
|
+
context = ""
|
|
42
|
+
context += "## Card Details\n#{card_details}\n\n" unless card_details.empty?
|
|
43
|
+
context += "## Recent Comments\n#{card_comments}\n" unless card_comments.empty?
|
|
44
|
+
context
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def fetch_card_details(card_number, repo_path:, env:)
|
|
48
|
+
output = run_cmd("fizzy", "card", "show", card_number.to_s, chdir: repo_path, env: env)
|
|
49
|
+
card = JSON.parse(output)["data"]
|
|
50
|
+
return "" unless card
|
|
51
|
+
|
|
52
|
+
parts = []
|
|
53
|
+
parts << "**Title:** #{card["title"]}"
|
|
54
|
+
parts << "**Body:**\n#{card.dig("body", "plain_text")}" if card.dig("body", "plain_text")
|
|
55
|
+
parts.join("\n")
|
|
56
|
+
rescue StandardError => e
|
|
57
|
+
LOG.warn "[Fizzy] Could not fetch card ##{card_number}: #{e.message}" if defined?(LOG)
|
|
58
|
+
""
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def fetch_card_comments(card_number, repo_path:, env:)
|
|
62
|
+
output = run_cmd("fizzy", "comment", "list", "--card", card_number.to_s, chdir: repo_path, env: env)
|
|
63
|
+
comments = JSON.parse(output)["data"] || []
|
|
64
|
+
return "" if comments.empty?
|
|
65
|
+
|
|
66
|
+
comments.last(15).map do |c|
|
|
67
|
+
body = c.dig("body", "plain_text") || ""
|
|
68
|
+
body = "#{body[0..500]}..." if body.length > 500
|
|
69
|
+
"**#{c["creator_name"]}** (#{c["id"]}):\n#{body}"
|
|
70
|
+
end.join("\n\n---\n\n")
|
|
71
|
+
rescue StandardError => e
|
|
72
|
+
LOG.warn "[Fizzy] Could not fetch comments for card ##{card_number}: #{e.message}" if defined?(LOG)
|
|
73
|
+
""
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def move_card_to_column(card_number, column_name, project_config:, agent_name: nil)
|
|
77
|
+
board_key = Config.board_key_for_project(project_config)
|
|
78
|
+
column_id = Config.board_column_id(board_key, column_name) if board_key
|
|
79
|
+
return unless column_id
|
|
80
|
+
|
|
81
|
+
repo_path = project_config["repo_path"]
|
|
82
|
+
env = fizzy_env_for(agent_name || AI_AGENT_NAME)
|
|
83
|
+
run_cmd("fizzy", "card", "column", card_number.to_s, "--column", column_id, chdir: repo_path, env: env)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def append_fizzy_comment_footer(card_number, project_config:, agent_name: nil)
|
|
87
|
+
repo_path = project_config["repo_path"]
|
|
88
|
+
env = fizzy_env_for(agent_name || AI_AGENT_NAME)
|
|
89
|
+
|
|
90
|
+
output = run_cmd("fizzy", "comment", "list", "--card", card_number.to_s, chdir: repo_path, env: env)
|
|
91
|
+
comments = JSON.parse(output)["data"] || []
|
|
92
|
+
agent_display = agent_display_name(agent_name || AI_AGENT_NAME)
|
|
93
|
+
|
|
94
|
+
last_agent_comment = comments.reverse.find do |c|
|
|
95
|
+
c["creator_name"]&.downcase == agent_display.downcase
|
|
96
|
+
end
|
|
97
|
+
return unless last_agent_comment
|
|
98
|
+
|
|
99
|
+
# Check if footer already exists
|
|
100
|
+
body = last_agent_comment.dig("body", "html") || ""
|
|
101
|
+
return if body.include?("<em>Branch:")
|
|
102
|
+
|
|
103
|
+
# Detect branch from comment content or card map
|
|
104
|
+
branch = detect_branch_from_comment(body, card_number)
|
|
105
|
+
return unless branch
|
|
106
|
+
|
|
107
|
+
pr_url = detect_pr_url(branch, project_config)
|
|
108
|
+
footer = "<p><em>Branch: <code>#{branch}</code>"
|
|
109
|
+
footer += " | <a href=\"#{pr_url}\">PR</a>" if pr_url
|
|
110
|
+
footer += "</em></p>"
|
|
111
|
+
|
|
112
|
+
updated_body = body + footer
|
|
113
|
+
run_cmd("fizzy", "comment", "update", last_agent_comment["id"], "--card", card_number.to_s,
|
|
114
|
+
"--body", updated_body, chdir: repo_path, env: env)
|
|
115
|
+
rescue StandardError => e
|
|
116
|
+
LOG.warn "[Fizzy] Could not append footer to card ##{card_number}: #{e.message}" if defined?(LOG)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def ensure_fizzy_yaml!(chdir, project_config)
|
|
120
|
+
fizzy_yaml_dest = File.join(chdir, ".fizzy.yaml")
|
|
121
|
+
return if File.exist?(fizzy_yaml_dest)
|
|
122
|
+
|
|
123
|
+
fizzy_yaml_src = File.join(project_config["repo_path"], ".fizzy.yaml")
|
|
124
|
+
return unless File.exist?(fizzy_yaml_src)
|
|
125
|
+
|
|
126
|
+
FileUtils.cp(fizzy_yaml_src, fizzy_yaml_dest)
|
|
127
|
+
LOG.info "[Fizzy] Copied .fizzy.yaml to #{chdir}" if defined?(LOG)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def scrub_invalid_attachments!(dir)
|
|
131
|
+
attachments_dir = File.join(dir, ".fizzy-attachments")
|
|
132
|
+
return unless Dir.exist?(attachments_dir)
|
|
133
|
+
|
|
134
|
+
Dir.glob(File.join(attachments_dir, "*")).each do |file|
|
|
135
|
+
next unless File.file?(file)
|
|
136
|
+
next if File.size(file) > 100 # Keep files with real content
|
|
137
|
+
|
|
138
|
+
File.delete(file)
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
private
|
|
143
|
+
|
|
144
|
+
def detect_branch_from_comment(body, card_number)
|
|
145
|
+
# Try to find branch in comment body
|
|
146
|
+
match = body.match(%r{<code>(fizzy-#{card_number}-[^<]+)</code>})
|
|
147
|
+
return match[1] if match
|
|
148
|
+
|
|
149
|
+
# Fall back to card map
|
|
150
|
+
map = load_work_item_map
|
|
151
|
+
entry = map.values.find { |v| v["number"].to_s == card_number.to_s }
|
|
152
|
+
entry&.dig("branch")
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def detect_pr_url(branch, project_config)
|
|
156
|
+
repo = project_config["github_repo"]
|
|
157
|
+
return nil unless repo
|
|
158
|
+
|
|
159
|
+
"https://github.com/#{repo}/pull/new/#{branch}"
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|