brainzlab 0.1.1 → 0.1.3

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.
Files changed (100) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE +6 -21
  3. data/README.md +24 -2
  4. data/lib/brainzlab/beacon/client.rb +207 -0
  5. data/lib/brainzlab/beacon/provisioner.rb +44 -0
  6. data/lib/brainzlab/beacon.rb +215 -0
  7. data/lib/brainzlab/configuration.rb +372 -32
  8. data/lib/brainzlab/context.rb +2 -3
  9. data/lib/brainzlab/cortex/cache.rb +59 -0
  10. data/lib/brainzlab/cortex/client.rb +139 -0
  11. data/lib/brainzlab/cortex/provisioner.rb +49 -0
  12. data/lib/brainzlab/cortex.rb +223 -0
  13. data/lib/brainzlab/dendrite/client.rb +230 -0
  14. data/lib/brainzlab/dendrite/provisioner.rb +44 -0
  15. data/lib/brainzlab/dendrite.rb +195 -0
  16. data/lib/brainzlab/devtools/assets/devtools.css +1106 -0
  17. data/lib/brainzlab/devtools/assets/devtools.js +322 -0
  18. data/lib/brainzlab/devtools/assets/logo.svg +6 -0
  19. data/lib/brainzlab/devtools/assets/templates/debug_panel.html.erb +500 -0
  20. data/lib/brainzlab/devtools/assets/templates/error_page.html.erb +1086 -0
  21. data/lib/brainzlab/devtools/data/collector.rb +248 -0
  22. data/lib/brainzlab/devtools/middleware/asset_server.rb +63 -0
  23. data/lib/brainzlab/devtools/middleware/database_handler.rb +177 -0
  24. data/lib/brainzlab/devtools/middleware/debug_panel.rb +126 -0
  25. data/lib/brainzlab/devtools/middleware/error_page.rb +377 -0
  26. data/lib/brainzlab/devtools/renderers/debug_panel_renderer.rb +159 -0
  27. data/lib/brainzlab/devtools/renderers/error_page_renderer.rb +98 -0
  28. data/lib/brainzlab/devtools.rb +75 -0
  29. data/lib/brainzlab/flux/buffer.rb +96 -0
  30. data/lib/brainzlab/flux/client.rb +68 -0
  31. data/lib/brainzlab/flux/provisioner.rb +57 -0
  32. data/lib/brainzlab/flux.rb +174 -0
  33. data/lib/brainzlab/instrumentation/action_mailer.rb +14 -13
  34. data/lib/brainzlab/instrumentation/active_record.rb +28 -13
  35. data/lib/brainzlab/instrumentation/aws.rb +183 -0
  36. data/lib/brainzlab/instrumentation/dalli.rb +108 -0
  37. data/lib/brainzlab/instrumentation/delayed_job.rb +27 -29
  38. data/lib/brainzlab/instrumentation/elasticsearch.rb +23 -24
  39. data/lib/brainzlab/instrumentation/excon.rb +152 -0
  40. data/lib/brainzlab/instrumentation/faraday.rb +3 -4
  41. data/lib/brainzlab/instrumentation/good_job.rb +102 -0
  42. data/lib/brainzlab/instrumentation/grape.rb +24 -24
  43. data/lib/brainzlab/instrumentation/graphql.rb +24 -23
  44. data/lib/brainzlab/instrumentation/httparty.rb +13 -14
  45. data/lib/brainzlab/instrumentation/mongodb.rb +7 -7
  46. data/lib/brainzlab/instrumentation/net_http.rb +6 -6
  47. data/lib/brainzlab/instrumentation/redis.rb +14 -21
  48. data/lib/brainzlab/instrumentation/resque.rb +114 -0
  49. data/lib/brainzlab/instrumentation/sidekiq.rb +29 -28
  50. data/lib/brainzlab/instrumentation/solid_queue.rb +194 -0
  51. data/lib/brainzlab/instrumentation/stripe.rb +163 -0
  52. data/lib/brainzlab/instrumentation/typhoeus.rb +106 -0
  53. data/lib/brainzlab/instrumentation.rb +84 -12
  54. data/lib/brainzlab/nerve/client.rb +215 -0
  55. data/lib/brainzlab/nerve/provisioner.rb +44 -0
  56. data/lib/brainzlab/nerve.rb +219 -0
  57. data/lib/brainzlab/pulse/client.rb +15 -11
  58. data/lib/brainzlab/pulse/instrumentation.rb +90 -53
  59. data/lib/brainzlab/pulse/propagation.rb +29 -29
  60. data/lib/brainzlab/pulse/provisioner.rb +12 -12
  61. data/lib/brainzlab/pulse/tracer.rb +4 -4
  62. data/lib/brainzlab/pulse.rb +14 -14
  63. data/lib/brainzlab/rails/log_formatter.rb +127 -121
  64. data/lib/brainzlab/rails/log_subscriber.rb +70 -77
  65. data/lib/brainzlab/rails/railtie.rb +96 -86
  66. data/lib/brainzlab/recall/buffer.rb +1 -1
  67. data/lib/brainzlab/recall/client.rb +14 -10
  68. data/lib/brainzlab/recall/logger.rb +16 -18
  69. data/lib/brainzlab/recall/provisioner.rb +29 -12
  70. data/lib/brainzlab/recall.rb +14 -11
  71. data/lib/brainzlab/reflex/breadcrumbs.rb +2 -2
  72. data/lib/brainzlab/reflex/client.rb +14 -10
  73. data/lib/brainzlab/reflex/provisioner.rb +12 -12
  74. data/lib/brainzlab/reflex.rb +31 -31
  75. data/lib/brainzlab/sentinel/client.rb +216 -0
  76. data/lib/brainzlab/sentinel/provisioner.rb +44 -0
  77. data/lib/brainzlab/sentinel.rb +165 -0
  78. data/lib/brainzlab/signal/client.rb +60 -0
  79. data/lib/brainzlab/signal/provisioner.rb +55 -0
  80. data/lib/brainzlab/signal.rb +136 -0
  81. data/lib/brainzlab/synapse/client.rb +288 -0
  82. data/lib/brainzlab/synapse/provisioner.rb +44 -0
  83. data/lib/brainzlab/synapse.rb +270 -0
  84. data/lib/brainzlab/utilities/circuit_breaker.rb +261 -0
  85. data/lib/brainzlab/utilities/health_check.rb +294 -0
  86. data/lib/brainzlab/utilities/log_formatter.rb +254 -0
  87. data/lib/brainzlab/utilities/rate_limiter.rb +230 -0
  88. data/lib/brainzlab/utilities.rb +17 -0
  89. data/lib/brainzlab/vault/cache.rb +80 -0
  90. data/lib/brainzlab/vault/client.rb +196 -0
  91. data/lib/brainzlab/vault/provisioner.rb +49 -0
  92. data/lib/brainzlab/vault.rb +262 -0
  93. data/lib/brainzlab/version.rb +1 -1
  94. data/lib/brainzlab/vision/client.rb +128 -0
  95. data/lib/brainzlab/vision/provisioner.rb +136 -0
  96. data/lib/brainzlab/vision.rb +155 -0
  97. data/lib/brainzlab-sdk.rb +1 -1
  98. data/lib/brainzlab.rb +112 -13
  99. data/lib/generators/brainzlab/install/install_generator.rb +29 -27
  100. metadata +60 -1
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'net/http'
4
+ require 'json'
5
+ require 'uri'
6
+ require 'cgi'
7
+
8
+ module BrainzLab
9
+ module Cortex
10
+ class Client
11
+ def initialize(config)
12
+ @config = config
13
+ @base_url = config.cortex_url || 'https://cortex.brainzlab.ai'
14
+ end
15
+
16
+ # Evaluate a single flag
17
+ def evaluate(flag_name, context: {})
18
+ response = request(
19
+ :post,
20
+ '/api/v1/evaluate',
21
+ body: {
22
+ flag: flag_name,
23
+ context: context
24
+ }
25
+ )
26
+
27
+ return nil unless response.is_a?(Net::HTTPSuccess)
28
+
29
+ data = JSON.parse(response.body, symbolize_names: true)
30
+ data[:result]
31
+ rescue StandardError => e
32
+ log_error('evaluate', e)
33
+ nil
34
+ end
35
+
36
+ # Evaluate multiple flags at once
37
+ def evaluate_all(context: {})
38
+ response = request(
39
+ :post,
40
+ '/api/v1/evaluate/batch',
41
+ body: { context: context }
42
+ )
43
+
44
+ return {} unless response.is_a?(Net::HTTPSuccess)
45
+
46
+ data = JSON.parse(response.body, symbolize_names: true)
47
+ data[:flags] || {}
48
+ rescue StandardError => e
49
+ log_error('evaluate_all', e)
50
+ {}
51
+ end
52
+
53
+ # List all flags
54
+ def list
55
+ response = request(:get, '/api/v1/flags')
56
+
57
+ return [] unless response.is_a?(Net::HTTPSuccess)
58
+
59
+ data = JSON.parse(response.body, symbolize_names: true)
60
+ data[:flags] || []
61
+ rescue StandardError => e
62
+ log_error('list', e)
63
+ []
64
+ end
65
+
66
+ # Get flag details
67
+ def get_flag(flag_name)
68
+ response = request(:get, "/api/v1/flags/#{CGI.escape(flag_name.to_s)}")
69
+
70
+ return nil unless response.is_a?(Net::HTTPSuccess)
71
+
72
+ JSON.parse(response.body, symbolize_names: true)
73
+ rescue StandardError => e
74
+ log_error('get_flag', e)
75
+ nil
76
+ end
77
+
78
+ def provision(project_id:, app_name:)
79
+ response = request(
80
+ :post,
81
+ '/api/v1/projects/provision',
82
+ body: { project_id: project_id, app_name: app_name },
83
+ use_service_key: true
84
+ )
85
+
86
+ response.is_a?(Net::HTTPSuccess) || response.is_a?(Net::HTTPCreated)
87
+ rescue StandardError => e
88
+ log_error('provision', e)
89
+ false
90
+ end
91
+
92
+ private
93
+
94
+ def request(method, path, headers: {}, body: nil, params: nil, use_service_key: false)
95
+ uri = URI.parse("#{@base_url}#{path}")
96
+
97
+ uri.query = URI.encode_www_form(params) if params
98
+
99
+ http = Net::HTTP.new(uri.host, uri.port)
100
+ http.use_ssl = uri.scheme == 'https'
101
+ http.open_timeout = 5
102
+ http.read_timeout = 10
103
+
104
+ request = case method
105
+ when :get
106
+ Net::HTTP::Get.new(uri)
107
+ when :post
108
+ Net::HTTP::Post.new(uri)
109
+ when :put
110
+ Net::HTTP::Put.new(uri)
111
+ when :delete
112
+ Net::HTTP::Delete.new(uri)
113
+ end
114
+
115
+ # Set headers
116
+ request['Content-Type'] = 'application/json'
117
+ request['Accept'] = 'application/json'
118
+
119
+ if use_service_key
120
+ request['X-Service-Key'] = @config.cortex_master_key || @config.secret_key
121
+ else
122
+ auth_key = @config.cortex_api_key || @config.secret_key
123
+ request['Authorization'] = "Bearer #{auth_key}" if auth_key
124
+ end
125
+
126
+ headers.each { |k, v| request[k] = v }
127
+
128
+ # Set body
129
+ request.body = body.to_json if body
130
+
131
+ http.request(request)
132
+ end
133
+
134
+ def log_error(operation, error)
135
+ BrainzLab.debug_log("[Cortex::Client] #{operation} failed: #{error.message}")
136
+ end
137
+ end
138
+ end
139
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BrainzLab
4
+ module Cortex
5
+ class Provisioner
6
+ def initialize(config)
7
+ @config = config
8
+ @provisioned = false
9
+ end
10
+
11
+ def ensure_project!
12
+ return if @provisioned
13
+ return unless @config.cortex_auto_provision
14
+ return unless valid_auth?
15
+
16
+ @provisioned = true
17
+
18
+ # Try to provision with Platform project ID
19
+ project_id = detect_project_id
20
+ return unless project_id
21
+
22
+ client = Client.new(@config)
23
+ client.provision(
24
+ project_id: project_id,
25
+ app_name: @config.app_name || @config.service
26
+ )
27
+
28
+ BrainzLab.debug_log("[Cortex::Provisioner] Project provisioned: #{project_id}")
29
+ rescue StandardError => e
30
+ BrainzLab.debug_log("[Cortex::Provisioner] Provisioning failed: #{e.message}")
31
+ end
32
+
33
+ private
34
+
35
+ def valid_auth?
36
+ key = @config.cortex_api_key || @config.cortex_master_key || @config.secret_key
37
+ !key.nil? && !key.empty?
38
+ end
39
+
40
+ def detect_project_id
41
+ # Try environment variable first
42
+ return ENV['BRAINZLAB_PROJECT_ID'] if ENV['BRAINZLAB_PROJECT_ID']
43
+
44
+ # Could also detect from Platform API if we have a secret key
45
+ nil
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,223 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'cortex/client'
4
+ require_relative 'cortex/cache'
5
+ require_relative 'cortex/provisioner'
6
+
7
+ module BrainzLab
8
+ module Cortex
9
+ class << self
10
+ # Check if a feature flag is enabled
11
+ # @param flag_name [String, Symbol] The flag name
12
+ # @param context [Hash] Evaluation context (user, attributes, etc.)
13
+ # @return [Boolean] True if the flag is enabled
14
+ #
15
+ # @example
16
+ # if BrainzLab::Cortex.enabled?(:new_checkout, user: current_user)
17
+ # render_new_checkout
18
+ # end
19
+ #
20
+ def enabled?(flag_name, **context)
21
+ result = get(flag_name, **context)
22
+ [true, 'true'].include?(result)
23
+ end
24
+
25
+ # Check if a feature flag is disabled
26
+ # @param flag_name [String, Symbol] The flag name
27
+ # @param context [Hash] Evaluation context
28
+ # @return [Boolean] True if the flag is disabled
29
+ def disabled?(flag_name, **context)
30
+ !enabled?(flag_name, **context)
31
+ end
32
+
33
+ # Get the value of a feature flag
34
+ # @param flag_name [String, Symbol] The flag name
35
+ # @param context [Hash] Evaluation context
36
+ # @param default [Object] Default value if flag not found
37
+ # @return [Object] The flag value
38
+ #
39
+ # @example
40
+ # limit = BrainzLab::Cortex.get(:rate_limit, user: current_user, default: 100)
41
+ #
42
+ def get(flag_name, default: nil, **context)
43
+ return default unless module_enabled?
44
+
45
+ ensure_provisioned!
46
+ return default unless BrainzLab.configuration.cortex_valid?
47
+
48
+ flag_key = flag_name.to_s
49
+ merged_context = merge_context(context)
50
+ cache_key = build_cache_key(flag_key, merged_context)
51
+
52
+ # Check cache first
53
+ return cache.get(cache_key) if BrainzLab.configuration.cortex_cache_enabled && cache.has?(cache_key)
54
+
55
+ result = client.evaluate(flag_key, context: merged_context)
56
+
57
+ if result.nil?
58
+ default
59
+ else
60
+ cache.set(cache_key, result) if BrainzLab.configuration.cortex_cache_enabled
61
+ result
62
+ end
63
+ end
64
+
65
+ # Get the variant for an A/B test flag
66
+ # @param flag_name [String, Symbol] The flag name
67
+ # @param context [Hash] Evaluation context
68
+ # @param default [String] Default variant if flag not found
69
+ # @return [String, nil] The variant name
70
+ #
71
+ # @example
72
+ # variant = BrainzLab::Cortex.variant(:checkout_experiment, user: current_user)
73
+ # case variant
74
+ # when "control" then render_control
75
+ # when "treatment_a" then render_treatment_a
76
+ # when "treatment_b" then render_treatment_b
77
+ # end
78
+ #
79
+ def variant(flag_name, default: nil, **context)
80
+ result = get(flag_name, **context)
81
+ result.is_a?(String) ? result : default
82
+ end
83
+
84
+ # Get all flags for a context
85
+ # @param context [Hash] Evaluation context
86
+ # @return [Hash] All flag values
87
+ #
88
+ # @example
89
+ # flags = BrainzLab::Cortex.all(user: current_user)
90
+ # flags[:new_checkout] # => true
91
+ # flags[:rate_limit] # => 200
92
+ #
93
+ def all(**context)
94
+ return {} unless module_enabled?
95
+
96
+ ensure_provisioned!
97
+ return {} unless BrainzLab.configuration.cortex_valid?
98
+
99
+ merged_context = merge_context(context)
100
+ client.evaluate_all(context: merged_context)
101
+ end
102
+
103
+ # List all flag definitions
104
+ # @return [Array<Hash>] List of flag metadata
105
+ def list_flags
106
+ return [] unless module_enabled?
107
+
108
+ ensure_provisioned!
109
+ return [] unless BrainzLab.configuration.cortex_valid?
110
+
111
+ client.list
112
+ end
113
+
114
+ # Get a flag's configuration
115
+ # @param flag_name [String, Symbol] The flag name
116
+ # @return [Hash, nil] Flag configuration
117
+ def flag_config(flag_name)
118
+ return nil unless module_enabled?
119
+
120
+ ensure_provisioned!
121
+ return nil unless BrainzLab.configuration.cortex_valid?
122
+
123
+ client.get_flag(flag_name.to_s)
124
+ end
125
+
126
+ # Clear the flag cache
127
+ def clear_cache!
128
+ cache.clear!
129
+ end
130
+
131
+ # === Context Helpers ===
132
+
133
+ # Set default context for all evaluations in current request
134
+ # @param context [Hash] Context to merge
135
+ def set_context(**context)
136
+ Thread.current[:cortex_context] = (Thread.current[:cortex_context] || {}).merge(context)
137
+ end
138
+
139
+ # Clear the current context
140
+ def clear_context!
141
+ Thread.current[:cortex_context] = nil
142
+ end
143
+
144
+ # Evaluate flags with a temporary context
145
+ # @param context [Hash] Temporary context
146
+ def with_context(**context)
147
+ previous = Thread.current[:cortex_context]
148
+ Thread.current[:cortex_context] = (previous || {}).merge(context)
149
+ yield
150
+ ensure
151
+ Thread.current[:cortex_context] = previous
152
+ end
153
+
154
+ # === INTERNAL ===
155
+
156
+ def ensure_provisioned!
157
+ return if @provisioned
158
+
159
+ @provisioned = true
160
+ provisioner.ensure_project!
161
+ end
162
+
163
+ def provisioner
164
+ @provisioner ||= Provisioner.new(BrainzLab.configuration)
165
+ end
166
+
167
+ def client
168
+ @client ||= Client.new(BrainzLab.configuration)
169
+ end
170
+
171
+ def cache
172
+ @cache ||= Cache.new(BrainzLab.configuration.cortex_cache_ttl)
173
+ end
174
+
175
+ def reset!
176
+ @client = nil
177
+ @provisioner = nil
178
+ @cache = nil
179
+ @provisioned = false
180
+ Thread.current[:cortex_context] = nil
181
+ end
182
+
183
+ private
184
+
185
+ def module_enabled?
186
+ BrainzLab.configuration.cortex_enabled
187
+ end
188
+
189
+ def merge_context(context)
190
+ default_context = BrainzLab.configuration.cortex_default_context || {}
191
+ thread_context = Thread.current[:cortex_context] || {}
192
+
193
+ # Also include user from BrainzLab context if available
194
+ brainzlab_context = {}
195
+ brainzlab_context[:user] = BrainzLab::Context.current.user if BrainzLab::Context.current.user
196
+
197
+ # Normalize user context
198
+ merged = default_context.merge(brainzlab_context).merge(thread_context).merge(context)
199
+
200
+ # Convert user object to hash if needed
201
+ if merged[:user].respond_to?(:id)
202
+ merged[:user] = {
203
+ id: merged[:user].id.to_s,
204
+ email: merged[:user].try(:email),
205
+ name: merged[:user].try(:name)
206
+ }.compact
207
+ end
208
+
209
+ merged
210
+ end
211
+
212
+ def build_cache_key(flag_name, context)
213
+ # Include relevant context in cache key
214
+ user_id = context.dig(:user, :id) || context[:user_id]
215
+ env = BrainzLab.configuration.environment
216
+
217
+ parts = [env, flag_name]
218
+ parts << "u:#{user_id}" if user_id
219
+ parts.join(':')
220
+ end
221
+ end
222
+ end
223
+ end
@@ -0,0 +1,230 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'net/http'
4
+ require 'json'
5
+ require 'uri'
6
+ require 'cgi'
7
+
8
+ module BrainzLab
9
+ module Dendrite
10
+ class Client
11
+ def initialize(config)
12
+ @config = config
13
+ @base_url = config.dendrite_url || 'https://dendrite.brainzlab.ai'
14
+ end
15
+
16
+ # Connect a repository
17
+ def connect_repository(url:, name: nil, branch: 'main', **options)
18
+ response = request(
19
+ :post,
20
+ '/api/v1/repositories',
21
+ body: {
22
+ url: url,
23
+ name: name,
24
+ branch: branch,
25
+ **options
26
+ }
27
+ )
28
+
29
+ return nil unless response.is_a?(Net::HTTPSuccess) || response.is_a?(Net::HTTPCreated)
30
+
31
+ JSON.parse(response.body, symbolize_names: true)
32
+ rescue StandardError => e
33
+ log_error('connect_repository', e)
34
+ nil
35
+ end
36
+
37
+ # Trigger sync for a repository
38
+ def sync_repository(repo_id)
39
+ response = request(:post, "/api/v1/repositories/#{repo_id}/sync")
40
+ response.is_a?(Net::HTTPSuccess) || response.is_a?(Net::HTTPAccepted)
41
+ rescue StandardError => e
42
+ log_error('sync_repository', e)
43
+ false
44
+ end
45
+
46
+ # Get repository status
47
+ def get_repository(repo_id)
48
+ response = request(:get, "/api/v1/repositories/#{repo_id}")
49
+
50
+ return nil unless response.is_a?(Net::HTTPSuccess)
51
+
52
+ JSON.parse(response.body, symbolize_names: true)
53
+ rescue StandardError => e
54
+ log_error('get_repository', e)
55
+ nil
56
+ end
57
+
58
+ # List repositories
59
+ def list_repositories
60
+ response = request(:get, '/api/v1/repositories')
61
+
62
+ return [] unless response.is_a?(Net::HTTPSuccess)
63
+
64
+ data = JSON.parse(response.body, symbolize_names: true)
65
+ data[:repositories] || []
66
+ rescue StandardError => e
67
+ log_error('list_repositories', e)
68
+ []
69
+ end
70
+
71
+ # Get wiki pages for a repository
72
+ def get_wiki(repo_id)
73
+ response = request(:get, "/api/v1/wiki/#{repo_id}")
74
+
75
+ return nil unless response.is_a?(Net::HTTPSuccess)
76
+
77
+ JSON.parse(response.body, symbolize_names: true)
78
+ rescue StandardError => e
79
+ log_error('get_wiki', e)
80
+ nil
81
+ end
82
+
83
+ # Get a specific wiki page
84
+ def get_wiki_page(repo_id, page_slug)
85
+ response = request(:get, "/api/v1/wiki/#{repo_id}/#{CGI.escape(page_slug)}")
86
+
87
+ return nil unless response.is_a?(Net::HTTPSuccess)
88
+
89
+ JSON.parse(response.body, symbolize_names: true)
90
+ rescue StandardError => e
91
+ log_error('get_wiki_page', e)
92
+ nil
93
+ end
94
+
95
+ # Semantic search across codebase
96
+ def search(repo_id, query, limit: 10)
97
+ response = request(
98
+ :get,
99
+ '/api/v1/search',
100
+ params: { repo_id: repo_id, q: query, limit: limit }
101
+ )
102
+
103
+ return [] unless response.is_a?(Net::HTTPSuccess)
104
+
105
+ data = JSON.parse(response.body, symbolize_names: true)
106
+ data[:results] || []
107
+ rescue StandardError => e
108
+ log_error('search', e)
109
+ []
110
+ end
111
+
112
+ # Ask a question about the codebase
113
+ def ask(repo_id, question, session_id: nil)
114
+ response = request(
115
+ :post,
116
+ '/api/v1/chat',
117
+ body: {
118
+ repo_id: repo_id,
119
+ question: question,
120
+ session_id: session_id
121
+ }
122
+ )
123
+
124
+ return nil unless response.is_a?(Net::HTTPSuccess)
125
+
126
+ JSON.parse(response.body, symbolize_names: true)
127
+ rescue StandardError => e
128
+ log_error('ask', e)
129
+ nil
130
+ end
131
+
132
+ # Explain a specific file or function
133
+ def explain(repo_id, path, symbol: nil)
134
+ response = request(
135
+ :post,
136
+ '/api/v1/explain',
137
+ body: {
138
+ repo_id: repo_id,
139
+ path: path,
140
+ symbol: symbol
141
+ }
142
+ )
143
+
144
+ return nil unless response.is_a?(Net::HTTPSuccess)
145
+
146
+ JSON.parse(response.body, symbolize_names: true)
147
+ rescue StandardError => e
148
+ log_error('explain', e)
149
+ nil
150
+ end
151
+
152
+ # Generate a diagram
153
+ def generate_diagram(repo_id, type:, scope: nil)
154
+ response = request(
155
+ :post,
156
+ '/api/v1/diagrams',
157
+ body: {
158
+ repo_id: repo_id,
159
+ type: type, # class, er, sequence, architecture
160
+ scope: scope
161
+ }
162
+ )
163
+
164
+ return nil unless response.is_a?(Net::HTTPSuccess)
165
+
166
+ JSON.parse(response.body, symbolize_names: true)
167
+ rescue StandardError => e
168
+ log_error('generate_diagram', e)
169
+ nil
170
+ end
171
+
172
+ def provision(project_id:, app_name:)
173
+ response = request(
174
+ :post,
175
+ '/api/v1/projects/provision',
176
+ body: { project_id: project_id, app_name: app_name },
177
+ use_service_key: true
178
+ )
179
+
180
+ response.is_a?(Net::HTTPSuccess) || response.is_a?(Net::HTTPCreated)
181
+ rescue StandardError => e
182
+ log_error('provision', e)
183
+ false
184
+ end
185
+
186
+ private
187
+
188
+ def request(method, path, headers: {}, body: nil, params: nil, use_service_key: false)
189
+ uri = URI.parse("#{@base_url}#{path}")
190
+
191
+ uri.query = URI.encode_www_form(params) if params
192
+
193
+ http = Net::HTTP.new(uri.host, uri.port)
194
+ http.use_ssl = uri.scheme == 'https'
195
+ http.open_timeout = 10
196
+ http.read_timeout = 60 # Longer timeout for AI operations
197
+
198
+ request = case method
199
+ when :get
200
+ Net::HTTP::Get.new(uri)
201
+ when :post
202
+ Net::HTTP::Post.new(uri)
203
+ when :put
204
+ Net::HTTP::Put.new(uri)
205
+ when :delete
206
+ Net::HTTP::Delete.new(uri)
207
+ end
208
+
209
+ request['Content-Type'] = 'application/json'
210
+ request['Accept'] = 'application/json'
211
+
212
+ if use_service_key
213
+ request['X-Service-Key'] = @config.dendrite_master_key || @config.secret_key
214
+ else
215
+ auth_key = @config.dendrite_api_key || @config.secret_key
216
+ request['Authorization'] = "Bearer #{auth_key}" if auth_key
217
+ end
218
+
219
+ headers.each { |k, v| request[k] = v }
220
+ request.body = body.to_json if body
221
+
222
+ http.request(request)
223
+ end
224
+
225
+ def log_error(operation, error)
226
+ BrainzLab.debug_log("[Dendrite::Client] #{operation} failed: #{error.message}")
227
+ end
228
+ end
229
+ end
230
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BrainzLab
4
+ module Dendrite
5
+ class Provisioner
6
+ def initialize(config)
7
+ @config = config
8
+ @provisioned = false
9
+ end
10
+
11
+ def ensure_project!
12
+ return if @provisioned
13
+ return unless @config.dendrite_auto_provision
14
+ return unless valid_auth?
15
+
16
+ @provisioned = true
17
+
18
+ project_id = detect_project_id
19
+ return unless project_id
20
+
21
+ client = Client.new(@config)
22
+ client.provision(
23
+ project_id: project_id,
24
+ app_name: @config.app_name || @config.service
25
+ )
26
+
27
+ BrainzLab.debug_log("[Dendrite::Provisioner] Project provisioned: #{project_id}")
28
+ rescue StandardError => e
29
+ BrainzLab.debug_log("[Dendrite::Provisioner] Provisioning failed: #{e.message}")
30
+ end
31
+
32
+ private
33
+
34
+ def valid_auth?
35
+ key = @config.dendrite_api_key || @config.dendrite_master_key || @config.secret_key
36
+ !key.nil? && !key.empty?
37
+ end
38
+
39
+ def detect_project_id
40
+ ENV.fetch('BRAINZLAB_PROJECT_ID', nil)
41
+ end
42
+ end
43
+ end
44
+ end