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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 900a770925d9d311e242d2262ba1d55488f1f1d99b706940412c90354fbecbe7
4
+ data.tar.gz: 10745520e457d223051f8b02451b6648b2fbd63b541a595da7430ef5a22a2580
5
+ SHA512:
6
+ metadata.gz: b5da5cb4db94444d25292a4521b73229c2e0115eaa14a4f29e1ddc0de47d87cb7bf54294d16ae59615ba0691be76ebf25040895c78a460fa7492b1f12729ff23
7
+ data.tar.gz: 9d16fc1d6792ddfbd2a0ba983a9bf66b4dc46cf3d2ead4bc3c77a4b6222679d5835cb30161722224cae7578a2ddd2531075090bc1829072f7c343b7bdb30abab
data/README.md ADDED
@@ -0,0 +1,111 @@
1
+ # brainiac-fizzy
2
+
3
+ Fizzy card management plugin for [Brainiac](https://github.com/stowzilla/brainiac).
4
+
5
+ Handles the full Fizzy integration: card assignment, comment routing, @mentions, cross-agent reviews, duplicate detection, deploy shortcuts, and deployment environment tracking.
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ brainiac install fizzy
11
+ brainiac restart
12
+ ```
13
+
14
+ Or manually:
15
+
16
+ ```bash
17
+ gem install brainiac-fizzy
18
+ ```
19
+
20
+ Then add to `~/.brainiac/plugins.json`:
21
+
22
+ ```json
23
+ {
24
+ "plugins": [
25
+ { "name": "fizzy", "gem": "brainiac-fizzy" }
26
+ ]
27
+ }
28
+ ```
29
+
30
+ ## Configuration
31
+
32
+ Fizzy configuration lives in `~/.brainiac/fizzy.json` (same as before):
33
+
34
+ ```json
35
+ {
36
+ "authorized_users": [
37
+ { "id": "user-id-1", "name": "Andy", "human": true },
38
+ { "id": "agent-id-1", "name": "Galen", "human": false }
39
+ ],
40
+ "boards": {
41
+ "development": {
42
+ "board_id": "your-board-id",
43
+ "webhook_secret": "secret-for-this-board",
44
+ "columns": {
45
+ "right_now": "column-id",
46
+ "needs_review": "column-id",
47
+ "uat": "column-id"
48
+ }
49
+ }
50
+ }
51
+ }
52
+ ```
53
+
54
+ ## What This Plugin Handles
55
+
56
+ | Event | Action |
57
+ |-------|--------|
58
+ | Card assigned | Creates worktree, maps card to branch, dispatches assigned agent |
59
+ | Card published | Duplicate detection (trigram + semantic) |
60
+ | @mention in comment | Routes to mentioned agent (cross-agent reviews) |
61
+ | Follow-up comment | Runs card's assigned agent in existing worktree |
62
+ | Deploy shortcut | Clones branch to deployment environment (dev01, dev02) |
63
+
64
+ ## Webhook Setup
65
+
66
+ Set your Fizzy webhook URL to:
67
+ ```
68
+ https://your-ngrok.ngrok-free.app/fizzy/development
69
+ ```
70
+
71
+ Where `development` is the board key from `fizzy.json`. Set the secret to the board's `webhook_secret`.
72
+
73
+ ## Dependencies on Brainiac Core
74
+
75
+ This plugin runs inside the brainiac server process and uses core functions:
76
+
77
+ - `verify_signature!` — webhook HMAC verification
78
+ - `run_agent` — agent CLI dispatch
79
+ - `session_active?`, `already_processed?` — deduplication
80
+ - `identify_project_by_tags` — card-to-project mapping
81
+ - `create_or_reuse_worktree` — git worktree management
82
+ - `prefetch_card_context` — card body/comments pre-fetch
83
+ - `render_prompt` — prompt template composition
84
+ - `reload_projects!`, `reload_agent_registry!` — config hot-reload
85
+
86
+ These are all provided by the `brainiac` gem (runtime dependency).
87
+
88
+ ## Migrating from Built-in Handler
89
+
90
+ If upgrading from brainiac's built-in Fizzy handler:
91
+
92
+ 1. Install the plugin: `brainiac install fizzy`
93
+ 2. Disable the built-in handler in `~/.brainiac/brainiac.json`:
94
+ ```json
95
+ { "handlers": { "fizzy": false } }
96
+ ```
97
+ 3. Restart: `brainiac restart`
98
+
99
+ The plugin uses the exact same config files and functions — it's a drop-in replacement.
100
+
101
+ ## Development
102
+
103
+ ```bash
104
+ cd ~/Code/brainiac-fizzy
105
+ bundle install
106
+ rake test
107
+ ```
108
+
109
+ ## License
110
+
111
+ MIT
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Brainiac
4
+ module Plugins
5
+ module Fizzy
6
+ # Fizzy configuration — loads ~/.brainiac/fizzy.json.
7
+ # Provides board config, webhook secrets, authorized users, and column IDs.
8
+ module Config
9
+ FIZZY_CONFIG_FILE = File.join(
10
+ ENV.fetch("BRAINIAC_DIR", File.join(Dir.home, ".brainiac")),
11
+ "fizzy.json"
12
+ )
13
+
14
+ @config = {}
15
+ @boards = {}
16
+ @authorized_user_ids = []
17
+
18
+ class << self
19
+ attr_reader :config, :boards, :authorized_user_ids
20
+
21
+ def load!
22
+ @config = if File.exist?(FIZZY_CONFIG_FILE)
23
+ JSON.parse(File.read(FIZZY_CONFIG_FILE))
24
+ else
25
+ {}
26
+ end
27
+ @boards = @config["boards"] || {}
28
+ @authorized_user_ids = (@config["authorized_users"] || []).map { |u| u["id"] }
29
+ rescue JSON::ParserError => e
30
+ LOG.error "[Fizzy] Failed to parse fizzy.json: #{e.message}" if defined?(LOG)
31
+ @config = {}
32
+ @boards = {}
33
+ @authorized_user_ids = []
34
+ end
35
+
36
+ def reload!
37
+ load!
38
+ end
39
+
40
+ def current
41
+ @config
42
+ end
43
+
44
+ def board_config(board_key)
45
+ @boards[board_key.to_s]
46
+ end
47
+
48
+ def board_webhook_secret(board_key)
49
+ config = board_config(board_key)
50
+ config&.dig("webhook_secret") || ENV.fetch("FIZZY_WEBHOOK_SECRET", nil)
51
+ end
52
+
53
+ def board_column_id(board_key, column_name)
54
+ config = board_config(board_key)
55
+ config&.dig("columns", column_name.to_s)
56
+ end
57
+
58
+ def board_key_for_id(board_id)
59
+ @boards.each do |key, config|
60
+ return key if config["board_id"] == board_id
61
+ end
62
+ nil
63
+ end
64
+
65
+ def board_key_for_project(project_config)
66
+ fizzy_yaml = File.join(project_config["repo_path"], ".fizzy.yaml")
67
+ return nil unless File.exist?(fizzy_yaml)
68
+
69
+ require "yaml"
70
+ data = YAML.safe_load_file(fizzy_yaml)
71
+ board_id = data["board"]
72
+ board_key_for_id(board_id)
73
+ rescue StandardError => e
74
+ LOG.warn "[Fizzy] Could not read .fizzy.yaml: #{e.message}" if defined?(LOG)
75
+ nil
76
+ end
77
+
78
+ def authorized?(payload)
79
+ creator_id = payload.dig("creator", "id")
80
+ @authorized_user_ids.include?(creator_id)
81
+ end
82
+
83
+ def human_mentioned?(user_id)
84
+ user = (@config["authorized_users"] || []).find { |u| u["id"] == user_id }
85
+ user && user["human"]
86
+ end
87
+
88
+ def identify_project_by_tags(tags)
89
+ tag_names = tags.map { |t| t.is_a?(Hash) ? t["name"] : t.to_s }.map(&:downcase)
90
+
91
+ PROJECTS.each do |key, config|
92
+ project_tags = (config["fizzy_tags"] || []).map(&:downcase)
93
+ return [key, config] if tag_names.intersect?(project_tags)
94
+ end
95
+
96
+ # Fall back to default project
97
+ default_key = default_project_key
98
+ default_key ? [default_key, PROJECTS[default_key]] : nil
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Top-level convenience methods that delegate to Fizzy plugin modules.
4
+ #
5
+ # The handler files (assignment.rb, comments.rb, etc.) were originally
6
+ # top-level functions in brainiac core. They call helpers like
7
+ # `fizzy_env_for`, `identify_project_by_tags`, etc. as top-level methods.
8
+ #
9
+ # These delegators make them available at top level so the handler files
10
+ # work without modification.
11
+
12
+ def fizzy_token_for(agent_name)
13
+ Brainiac::Plugins::Fizzy::Helpers.fizzy_token_for(agent_name)
14
+ end
15
+
16
+ def fizzy_env_for(agent_name)
17
+ Brainiac::Plugins::Fizzy::Helpers.fizzy_env_for(agent_name)
18
+ end
19
+
20
+ def default_fizzy_env
21
+ Brainiac::Plugins::Fizzy::Helpers.default_fizzy_env
22
+ end
23
+
24
+ def prefetch_card_context(card_number, repo_path:, agent_name: nil)
25
+ Brainiac::Plugins::Fizzy::Helpers.prefetch_card_context(card_number, repo_path: repo_path, agent_name: agent_name)
26
+ end
27
+
28
+ def move_card_to_column(card_number, column_name, project_config:, agent_name: nil)
29
+ Brainiac::Plugins::Fizzy::Helpers.move_card_to_column(card_number, column_name, project_config: project_config, agent_name: agent_name)
30
+ end
31
+
32
+ def append_fizzy_comment_footer(card_number, project_config:, agent_name: nil)
33
+ Brainiac::Plugins::Fizzy::Helpers.append_fizzy_comment_footer(card_number, project_config: project_config, agent_name: agent_name)
34
+ end
35
+
36
+ def ensure_fizzy_yaml!(chdir, project_config)
37
+ Brainiac::Plugins::Fizzy::Helpers.ensure_fizzy_yaml!(chdir, project_config)
38
+ end
39
+
40
+ def scrub_invalid_attachments!(dir)
41
+ Brainiac::Plugins::Fizzy::Helpers.scrub_invalid_attachments!(dir)
42
+ end
43
+
44
+ def verify_fizzy_signature!(request, payload_body, board_key: nil)
45
+ Brainiac::Plugins::Fizzy::Helpers.verify_signature!(request, payload_body, board_key: board_key)
46
+ end
47
+
48
+ # Legacy alias used by handler files
49
+ def verify_signature!(request, payload_body, board_key: nil)
50
+ Brainiac::Plugins::Fizzy::Helpers.verify_signature!(request, payload_body, board_key: board_key)
51
+ end
52
+
53
+ # Config delegators
54
+ def identify_project_by_tags(tags)
55
+ Brainiac::Plugins::Fizzy::Config.identify_project_by_tags(tags)
56
+ end
57
+
58
+ def board_config(board_key)
59
+ Brainiac::Plugins::Fizzy::Config.board_config(board_key)
60
+ end
61
+
62
+ def board_webhook_secret(board_key)
63
+ Brainiac::Plugins::Fizzy::Config.board_webhook_secret(board_key)
64
+ end
65
+
66
+ def board_column_id(board_key, column_name)
67
+ Brainiac::Plugins::Fizzy::Config.board_column_id(board_key, column_name)
68
+ end
69
+
70
+ def board_key_for_project(project_config)
71
+ Brainiac::Plugins::Fizzy::Config.board_key_for_project(project_config)
72
+ end
73
+
74
+ def board_key_for_id(board_id)
75
+ Brainiac::Plugins::Fizzy::Config.board_key_for_id(board_id)
76
+ end
77
+
78
+ def authorized?(payload)
79
+ Brainiac::Plugins::Fizzy::Config.authorized?(payload)
80
+ end
81
+
82
+ def human_mentioned?(user_id)
83
+ Brainiac::Plugins::Fizzy::Config.human_mentioned?(user_id)
84
+ end
85
+
86
+ # Top-level prompt constants — handler files reference these directly
87
+ PROMPT_CARD_ASSIGNED = Brainiac::Plugins::Fizzy::Prompts::CARD_ASSIGNED
88
+ PROMPT_FOLLOWUP_WORKTREE = Brainiac::Plugins::Fizzy::Prompts::FOLLOWUP_WORKTREE
89
+ PROMPT_FOLLOWUP_NO_WORKTREE = Brainiac::Plugins::Fizzy::Prompts::FOLLOWUP_NO_WORKTREE
90
+ PROMPT_MENTION = Brainiac::Plugins::Fizzy::Prompts::MENTION
91
+ PROMPT_CROSS_AGENT_REVIEW = Brainiac::Plugins::Fizzy::Prompts::CROSS_AGENT_REVIEW
92
+
93
+ # Config constants — handler files reference these as top-level constants.
94
+ # These are evaluated after Config.load! has been called (during plugin register).
95
+ # Use a delegating object so it always reflects current config state.
96
+ FIZZY_CONFIG = Class.new do
97
+ def fetch(key, default = nil) = Brainiac::Plugins::Fizzy::Config.current.fetch(key, default)
98
+ def [](key) = Brainiac::Plugins::Fizzy::Config.current[key]
99
+ def dig(*keys) = Brainiac::Plugins::Fizzy::Config.current.dig(*keys)
100
+ end.new
101
+
102
+ AUTHORIZED_USER_IDS = Class.new do
103
+ def include?(id) = Brainiac::Plugins::Fizzy::Config.authorized_user_ids.include?(id)
104
+ def map(&) = Brainiac::Plugins::Fizzy::Config.authorized_user_ids.map(&)
105
+ def any?(&) = Brainiac::Plugins::Fizzy::Config.authorized_user_ids.any?(&)
106
+ end.new
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Fizzy card assignment handler.
4
+ #
5
+ # When a card is assigned to a local agent, creates a worktree, builds the prompt,
6
+ # and dispatches the agent to begin work.
7
+
8
+ def handle_card_assigned(payload)
9
+ eventable = payload["eventable"] || {}
10
+ assignees = eventable["assignees"] || []
11
+
12
+ local_names = local_agent_names
13
+ assigned_agent = assignees.map { |a| a["name"] }.find { |name| local_names.include?(name) }
14
+
15
+ assignee_names = assignees.map { |a| a["name"] }.join(", ")
16
+ LOG.info "[Fizzy] Card assigned to: [#{assignee_names}], local agents: [#{local_names.join(", ")}]"
17
+
18
+ return ignore_assignment("wrong assignee", assignee_names, local_names) unless assigned_agent
19
+ return ignore_unauthorized(payload, eventable) unless authorized?(payload)
20
+
21
+ card_number = eventable["number"]
22
+ card_internal_id = eventable["id"]
23
+ title = eventable["title"] || "untitled"
24
+ tags = eventable["tags"] || []
25
+
26
+ project_result = identify_project_by_tags(tags)
27
+ unless project_result
28
+ tag_names = tags.map { |t| t.is_a?(Hash) ? t["name"] : t }.join(", ")
29
+ LOG.warn "No project found for card ##{card_number} with tags: #{tag_names}"
30
+ return [200, { status: "ignored", reason: "no matching project" }.to_json]
31
+ end
32
+
33
+ project_key, project_config = project_result
34
+ repo_path = project_config["repo_path"]
35
+ branch = "fizzy-#{card_number}-#{slugify(title)}"
36
+
37
+ card_key = "card-#{card_number}"
38
+ if session_active?(card_key)
39
+ LOG.info "Skipping card ##{card_number} — agent session already active"
40
+ return [200, { status: "ignored", reason: "session already active" }.to_json]
41
+ end
42
+
43
+ LOG.info "Card ##{card_number} assigned to #{assigned_agent} for project '#{project_key}', " \
44
+ "creating worktree: #{branch} (model: #{detect_model(project_config, tags: tags) || "default"})"
45
+
46
+ react_to_assignment(card_number, repo_path, assigned_agent)
47
+ worktree_path = setup_assigned_worktree(repo_path, branch, card_internal_id, card_number, project_key, assigned_agent)
48
+
49
+ dispatch_assigned_card(
50
+ card_number: card_number, card_internal_id: card_internal_id, title: title, tags: tags,
51
+ branch: branch, worktree_path: worktree_path, project_config: project_config, project_key: project_key,
52
+ agent_name: assigned_agent, model: detect_model(project_config, tags: tags),
53
+ effort: detect_effort(project_config, tags: tags), cli_provider_override: detect_cli_provider(tags: tags)
54
+ )
55
+ end
56
+
57
+ def ignore_assignment(reason, assignee_names, local_names)
58
+ LOG.info "[Fizzy] No local agent matched. Assignees: [#{assignee_names}], Local: [#{local_names.join(", ")}]"
59
+ [200, { status: "ignored", reason: reason }.to_json]
60
+ end
61
+
62
+ def ignore_unauthorized(payload, eventable)
63
+ creator_name = payload.dig("creator", "name") || "Unknown"
64
+ notify_unauthorized("card_assigned", creator_name, "card ##{eventable["number"]}")
65
+ [200, { status: "ignored", reason: "unauthorized" }.to_json]
66
+ end
67
+
68
+ def react_to_assignment(card_number, repo_path, agent_name)
69
+ Thread.new do
70
+ run_cmd("fizzy", "reaction", "create", "--card", card_number.to_s,
71
+ "--content", "👍", chdir: repo_path, env: fizzy_env_for(agent_name))
72
+ rescue StandardError => e
73
+ LOG.warn "Could not add reaction to card: #{e.message}"
74
+ end
75
+ end
76
+
77
+ def setup_assigned_worktree(repo_path, branch, card_internal_id, card_number, project_key, agent_name)
78
+ debounced_repo_fetch(repo_path)
79
+ worktree_path = File.join(File.dirname(repo_path), "#{File.basename(repo_path)}--#{branch}")
80
+ worktree_path = create_or_reuse_worktree(repo_path: repo_path, branch: branch, worktree_path: worktree_path)
81
+
82
+ map = load_work_item_map
83
+ map[card_internal_id] = {
84
+ "number" => card_number, "branch" => branch, "worktree" => worktree_path,
85
+ "project" => project_key, "agent" => agent_name
86
+ }
87
+ save_work_item_map(map)
88
+ worktree_path
89
+ end
90
+
91
+ def dispatch_assigned_card(card_number:, card_internal_id:, title:, tags:, branch:, worktree_path:,
92
+ project_config:, project_key:, agent_name:, model:, effort:, cli_provider_override:)
93
+ card_context = prefetch_card_context(card_number, repo_path: project_config["repo_path"], agent_name: agent_name)
94
+ planning_info = detect_planning_mode(text: title, tags: tags, card_internal_id: card_internal_id, card_number: card_number)
95
+
96
+ template_vars = {
97
+ "CARD_NUMBER" => card_number, "CARD_TITLE" => title,
98
+ "BRANCH" => branch, "COMMENT_CREATOR" => agent_name
99
+ }
100
+ brain_ctx = build_brain_context(
101
+ agent_name: agent_name, card_title: title,
102
+ card_number: card_number, project_key: project_key, source: :fizzy
103
+ )
104
+
105
+ prompt = if planning_info
106
+ LOG.info "[Planning] Planning mode active for card ##{card_number}"
107
+ template_vars["CARD_ID"] = planning_info[:card_id]
108
+ render_planning_prompt(PROMPT_CARD_ASSIGNED, template_vars,
109
+ brain_context: brain_ctx, card_context: card_context, agent_name: agent_name)
110
+ else
111
+ template_vars["CARD_ID"] = card_number
112
+ render_prompt(PROMPT_CARD_ASSIGNED, template_vars,
113
+ brain_context: brain_ctx, card_context: card_context, agent_name: agent_name)
114
+ end
115
+
116
+ card_key = "card-#{card_number}"
117
+ pid, log_file = run_agent(prompt,
118
+ project_config: project_config, chdir: worktree_path,
119
+ log_name: "assigned-#{card_number}", model: model, effort: effort,
120
+ agent_name: agent_name, card_number: card_number, source: :fizzy,
121
+ source_context: { card_number: card_number },
122
+ cli_provider: cli_provider_override)
123
+ register_session(card_key, pid, log_file: log_file, supersede_key: card_key, agent_name: agent_name)
124
+
125
+ Thread.new { move_card_to_column(card_number, "right_now", project_config: project_config, agent_name: agent_name) }
126
+
127
+ [200, { status: "processed", card: card_number, branch: branch, project: project_key, agent: agent_name }.to_json]
128
+ end