rails-informant 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.
Files changed (63) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +340 -0
  4. data/Rakefile +10 -0
  5. data/VERSION +1 -0
  6. data/app/controllers/rails_informant/api/base_controller.rb +62 -0
  7. data/app/controllers/rails_informant/api/errors_controller.rb +72 -0
  8. data/app/controllers/rails_informant/api/occurrences_controller.rb +14 -0
  9. data/app/controllers/rails_informant/api/status_controller.rb +29 -0
  10. data/app/jobs/rails_informant/application_job.rb +4 -0
  11. data/app/jobs/rails_informant/notify_job.rb +34 -0
  12. data/app/jobs/rails_informant/purge_job.rb +29 -0
  13. data/app/models/rails_informant/application_record.rb +5 -0
  14. data/app/models/rails_informant/error_group.rb +175 -0
  15. data/app/models/rails_informant/occurrence.rb +22 -0
  16. data/config/routes.rb +14 -0
  17. data/db/migrate/20260227000000_create_informant_tables.rb +65 -0
  18. data/exe/informant-mcp +27 -0
  19. data/lib/generators/rails_informant/devin/templates/error-triage.devin.md +48 -0
  20. data/lib/generators/rails_informant/devin_generator.rb +12 -0
  21. data/lib/generators/rails_informant/install_generator.rb +20 -0
  22. data/lib/generators/rails_informant/skill/templates/SKILL.md +168 -0
  23. data/lib/generators/rails_informant/skill_generator.rb +12 -0
  24. data/lib/generators/rails_informant/templates/create_informant_tables.rb.erb +55 -0
  25. data/lib/generators/rails_informant/templates/initializer.rb.erb +33 -0
  26. data/lib/rails_informant/breadcrumb_buffer.rb +30 -0
  27. data/lib/rails_informant/breadcrumb_subscriber.rb +51 -0
  28. data/lib/rails_informant/configuration.rb +51 -0
  29. data/lib/rails_informant/context_builder.rb +142 -0
  30. data/lib/rails_informant/context_filter.rb +45 -0
  31. data/lib/rails_informant/current.rb +5 -0
  32. data/lib/rails_informant/engine.rb +86 -0
  33. data/lib/rails_informant/error_recorder.rb +47 -0
  34. data/lib/rails_informant/error_subscriber.rb +17 -0
  35. data/lib/rails_informant/fingerprint.rb +23 -0
  36. data/lib/rails_informant/mcp/base_tool.rb +38 -0
  37. data/lib/rails_informant/mcp/client.rb +123 -0
  38. data/lib/rails_informant/mcp/configuration.rb +90 -0
  39. data/lib/rails_informant/mcp/server.rb +29 -0
  40. data/lib/rails_informant/mcp/tools/annotate_error.rb +25 -0
  41. data/lib/rails_informant/mcp/tools/delete_error.rb +25 -0
  42. data/lib/rails_informant/mcp/tools/get_error.rb +24 -0
  43. data/lib/rails_informant/mcp/tools/get_informant_status.rb +22 -0
  44. data/lib/rails_informant/mcp/tools/ignore_error.rb +24 -0
  45. data/lib/rails_informant/mcp/tools/list_environments.rb +20 -0
  46. data/lib/rails_informant/mcp/tools/list_errors.rb +32 -0
  47. data/lib/rails_informant/mcp/tools/list_occurrences.rb +27 -0
  48. data/lib/rails_informant/mcp/tools/mark_duplicate.rb +25 -0
  49. data/lib/rails_informant/mcp/tools/mark_fix_pending.rb +27 -0
  50. data/lib/rails_informant/mcp/tools/reopen_error.rb +24 -0
  51. data/lib/rails_informant/mcp/tools/resolve_error.rb +24 -0
  52. data/lib/rails_informant/mcp.rb +22 -0
  53. data/lib/rails_informant/middleware/error_capture.rb +28 -0
  54. data/lib/rails_informant/middleware/rescued_exception_interceptor.rb +16 -0
  55. data/lib/rails_informant/notifiers/devin.rb +61 -0
  56. data/lib/rails_informant/notifiers/notification_policy.rb +85 -0
  57. data/lib/rails_informant/notifiers/slack.rb +77 -0
  58. data/lib/rails_informant/notifiers/webhook.rb +31 -0
  59. data/lib/rails_informant/structured_event_subscriber.rb +14 -0
  60. data/lib/rails_informant/version.rb +3 -0
  61. data/lib/rails_informant.rb +147 -0
  62. data/lib/tasks/rails_informant.rake +30 -0
  63. metadata +177 -0
@@ -0,0 +1,30 @@
1
+ module RailsInformant
2
+ class BreadcrumbBuffer
3
+ CAPACITY = 50
4
+
5
+ def self.current
6
+ RailsInformant::Current.breadcrumbs ||= new
7
+ end
8
+
9
+ def initialize
10
+ @crumbs = []
11
+ end
12
+
13
+ def record(category:, message:, metadata: {}, duration: nil)
14
+ @crumbs << {
15
+ category:,
16
+ message:,
17
+ metadata:,
18
+ duration:,
19
+ timestamp: Time.current.iso8601(3)
20
+ }
21
+ @crumbs.shift if @crumbs.size > CAPACITY
22
+ end
23
+
24
+ def flush
25
+ result = @crumbs
26
+ @crumbs = []
27
+ result
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,51 @@
1
+ module RailsInformant
2
+ class BreadcrumbSubscriber
3
+ NEEDS_FILTERING = %w[
4
+ process_action.action_controller
5
+ redirect_to.action_controller
6
+ start_processing.action_controller
7
+ ].to_set.freeze
8
+
9
+ SUBSCRIPTIONS = {
10
+ "cache_fetch_hit.active_support" => %i[key],
11
+ "cache_read.active_support" => %i[key hit],
12
+ "cache_write.active_support" => %i[key],
13
+ "deliver.action_mailer" => %i[mailer],
14
+ "halted_callback.action_controller" => %i[filter],
15
+ "instantiation.active_record" => %i[record_count class_name],
16
+ "perform.active_job" => %i[job],
17
+ "perform_action.action_cable" => %i[channel_class action],
18
+ "perform_start.active_job" => %i[job],
19
+ "process_action.action_controller" => %i[controller action method path status],
20
+ "redirect_to.action_controller" => %i[status location],
21
+ "render_collection.action_view" => %i[identifier count],
22
+ "render_partial.action_view" => %i[identifier],
23
+ "render_template.action_view" => %i[identifier],
24
+ "sql.active_record" => %i[name],
25
+ "start_processing.action_controller" => %i[action controller format method path]
26
+ }.freeze
27
+
28
+ def self.subscribe!
29
+ SUBSCRIPTIONS.each do |event_name, allowed_keys|
30
+ message = event_name.split(".").first
31
+
32
+ ActiveSupport::Notifications.subscribe(event_name) do |event|
33
+ next unless RailsInformant.initialized?
34
+ next if event_name == "sql.active_record" && (event.payload[:cached] || event.payload[:name] == "SCHEMA")
35
+
36
+ filtered = event.payload.slice(*allowed_keys)
37
+ filtered = ContextFilter.filter(filtered) if NEEDS_FILTERING.include?(event_name)
38
+ if event_name == "redirect_to.action_controller" && filtered[:location]
39
+ filtered[:location] = ContextBuilder.filtered_url filtered[:location]
40
+ end
41
+ BreadcrumbBuffer.current.record(
42
+ category: event.name,
43
+ message:,
44
+ metadata: filtered,
45
+ duration: event.duration.round(1)
46
+ )
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,51 @@
1
+ module RailsInformant
2
+ class Configuration
3
+ attr_accessor :api_token,
4
+ :capture_errors,
5
+ :capture_user_email,
6
+ :devin_api_key,
7
+ :devin_playbook_id,
8
+ :ignored_exceptions,
9
+ :retention_days,
10
+ :slack_webhook_url,
11
+ :webhook_url
12
+
13
+ def initialize
14
+ @api_token = ENV["INFORMANT_API_TOKEN"]
15
+ @capture_errors = ENV.fetch("INFORMANT_CAPTURE_ERRORS", "true") != "false"
16
+ @capture_user_email = false
17
+ @custom_notifiers = []
18
+ @devin_api_key = ENV["INFORMANT_DEVIN_API_KEY"]
19
+ @devin_playbook_id = ENV["INFORMANT_DEVIN_PLAYBOOK_ID"]
20
+ @ignored_exceptions = ENV["INFORMANT_IGNORED_EXCEPTIONS"]&.split(",")&.map(&:strip) || []
21
+ @retention_days = ENV["INFORMANT_RETENTION_DAYS"]&.to_i
22
+ @slack_webhook_url = ENV["INFORMANT_SLACK_WEBHOOK_URL"]
23
+ @webhook_url = ENV["INFORMANT_WEBHOOK_URL"]
24
+ end
25
+
26
+ # Returns all notifiers: built-in (auto-registered from config) + custom.
27
+ def notifiers
28
+ @_notifiers ||= built_in_notifiers + @custom_notifiers
29
+ end
30
+
31
+ # Register a custom notifier. Must respond to #notify and #should_notify?.
32
+ def add_notifier(notifier)
33
+ @custom_notifiers << notifier
34
+ @_notifiers = nil
35
+ end
36
+
37
+ def reset_notifiers!
38
+ @_notifiers = nil
39
+ end
40
+
41
+ private
42
+
43
+ def built_in_notifiers
44
+ [
45
+ (Notifiers::Devin.new if devin_api_key.present? && devin_playbook_id.present?),
46
+ (Notifiers::Slack.new if slack_webhook_url.present?),
47
+ (Notifiers::Webhook.new if webhook_url.present?)
48
+ ].compact
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,142 @@
1
+ module RailsInformant
2
+ class ContextBuilder
3
+ MAX_CAUSE_DEPTH = 5
4
+ SKIP_HEADERS = Set.new(%w[
5
+ HTTP_AUTHORIZATION
6
+ HTTP_COOKIE
7
+ HTTP_PROXY_AUTHORIZATION
8
+ HTTP_X_API_KEY
9
+ HTTP_X_AUTH_TOKEN
10
+ HTTP_X_CSRF_TOKEN
11
+ ]).freeze
12
+
13
+ class << self
14
+ def build_exception_chain(error)
15
+ chain = []
16
+ current = error.cause
17
+ depth = 0
18
+
19
+ while current && depth < MAX_CAUSE_DEPTH
20
+ chain << {
21
+ class: current.class.name,
22
+ message: ContextFilter.filter_message(current.message),
23
+ backtrace: ContextFilter.filter_backtrace(current.backtrace)
24
+ }
25
+ current = current.cause
26
+ depth += 1
27
+ end
28
+
29
+ chain.presence
30
+ end
31
+
32
+ def build_request_context(env)
33
+ return unless env
34
+
35
+ request = ActionDispatch::Request.new(env)
36
+ ctx = {
37
+ url: filtered_url(request.original_url),
38
+ method: request.request_method,
39
+ params: request.filtered_parameters,
40
+ headers: extract_headers(request),
41
+ ip: request.remote_ip
42
+ }
43
+
44
+ ContextFilter.filter(ctx)
45
+ end
46
+
47
+ def build_user_context(env)
48
+ ctx = RailsInformant::Current.user_context || detect_current_user(env)
49
+ ContextFilter.filter(ctx)
50
+ end
51
+
52
+ def build_environment_context
53
+ @_static_env ||= {
54
+ rails_env: Rails.env.to_s,
55
+ ruby_version: RUBY_VERSION,
56
+ rails_version: Rails::VERSION::STRING,
57
+ hostname: Socket.gethostname
58
+ }.freeze
59
+ @_static_env.merge(pid: Process.pid)
60
+ end
61
+
62
+ def group_attributes(error, severity:, context:, env:, now:)
63
+ {
64
+ error_class: error.class.name, severity:,
65
+ message: ContextFilter.filter_message(error.message),
66
+ first_backtrace_line: Fingerprint.first_app_frame(error),
67
+ controller_action: extract_controller_action(env, context),
68
+ job_class: extract_job_class(context),
69
+ first_seen_at: now, last_seen_at: now,
70
+ total_occurrences: 1, created_at: now, updated_at: now
71
+ }
72
+ end
73
+
74
+ def occurrence_attributes(error, env:, context:)
75
+ {
76
+ backtrace: ContextFilter.filter_backtrace(error.backtrace),
77
+ exception_chain: build_exception_chain(error),
78
+ request_context: build_request_context(env),
79
+ user_context: build_user_context(env),
80
+ custom_context: ContextFilter.filter(RailsInformant::Current.custom_context),
81
+ environment_context: build_environment_context,
82
+ breadcrumbs: BreadcrumbBuffer.current.flush,
83
+ git_sha: RailsInformant.current_git_sha
84
+ }
85
+ end
86
+
87
+ def extract_controller_action(env, context)
88
+ if env
89
+ params = env["action_dispatch.request.parameters"]
90
+ "#{params["controller"]}##{params["action"]}" if params&.key?("controller") && params&.key?("action")
91
+ elsif context[:controller] && context[:action]
92
+ "#{context[:controller]}##{context[:action]}"
93
+ end
94
+ end
95
+
96
+ def extract_job_class(context)
97
+ context.dig(:job, :class) || context[:job_class]
98
+ end
99
+
100
+ def filtered_url(url)
101
+ uri = URI.parse(url)
102
+ if uri.query.present?
103
+ params = Rack::Utils.parse_query(uri.query)
104
+ filtered = ContextFilter.filter(params)
105
+ uri.query = Rack::Utils.build_query(filtered)
106
+ end
107
+ uri.to_s
108
+ rescue URI::InvalidURIError
109
+ url.split("?").first
110
+ end
111
+
112
+ private
113
+
114
+ def detect_current_user(env)
115
+ if defined?(::Current) && ::Current.respond_to?(:user) && ::Current.user
116
+ user_context ::Current.user
117
+ elsif env && env["warden"]&.user
118
+ user_context env["warden"].user
119
+ end
120
+ end
121
+
122
+ def extract_headers(request)
123
+ headers = {}
124
+ request.headers.each do |key, value|
125
+ next unless key.start_with?("HTTP_")
126
+ next if SKIP_HEADERS.include?(key)
127
+ header_name = key.delete_prefix("HTTP_").split("_").map(&:capitalize).join("-")
128
+ headers[header_name] = value
129
+ end
130
+ headers
131
+ end
132
+
133
+ def user_context(user)
134
+ return nil unless user
135
+
136
+ ctx = { id: user.id, class: user.class.name }
137
+ ctx[:email] = user.email if RailsInformant.capture_user_email && user.respond_to?(:email)
138
+ ctx
139
+ end
140
+ end
141
+ end
142
+ end
@@ -0,0 +1,45 @@
1
+ module RailsInformant
2
+ class ContextFilter
3
+ MAX_BACKTRACE_FRAMES = 200
4
+ MAX_MESSAGE_LENGTH = 2000
5
+ MAX_CONTEXT_SIZE = 64 * 1024 # 64 KB
6
+
7
+ class << self
8
+ def reset!
9
+ @_parameter_filter = nil
10
+ end
11
+
12
+ def filter(context)
13
+ return nil unless context
14
+
15
+ filtered = parameter_filter.filter(context)
16
+ truncate_to_size(filtered)
17
+ end
18
+
19
+ def filter_backtrace(backtrace)
20
+ return nil unless backtrace
21
+ backtrace.first(MAX_BACKTRACE_FRAMES)
22
+ end
23
+
24
+ def filter_message(message)
25
+ return nil unless message
26
+ message.to_s.truncate(MAX_MESSAGE_LENGTH)
27
+ end
28
+
29
+ private
30
+
31
+ def parameter_filter
32
+ @_parameter_filter ||= ActiveSupport::ParameterFilter.new(Rails.application.config.filter_parameters)
33
+ end
34
+
35
+ def truncate_to_size(data)
36
+ return data if data.is_a?(Hash) && data.size < 20
37
+
38
+ json = data.to_json
39
+ return data if json.bytesize <= MAX_CONTEXT_SIZE
40
+
41
+ { _truncated: true, _original_size: json.bytesize }
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,5 @@
1
+ module RailsInformant
2
+ class Current < ActiveSupport::CurrentAttributes
3
+ attribute :breadcrumbs, :user_context, :custom_context
4
+ end
5
+ end
@@ -0,0 +1,86 @@
1
+ module RailsInformant
2
+ class Engine < ::Rails::Engine
3
+ isolate_namespace RailsInformant
4
+
5
+ initializer "rails_informant.error_subscriber" do
6
+ next unless RailsInformant.capture_errors
7
+
8
+ Rails.error.subscribe RailsInformant::ErrorSubscriber.new
9
+ end
10
+
11
+ initializer "rails_informant.context_middleware" do
12
+ next unless RailsInformant.capture_errors
13
+
14
+ Rails.error.add_middleware ->(error, context) {
15
+ context.merge deploy_sha: RailsInformant.current_git_sha
16
+ }
17
+ end
18
+
19
+ initializer "rails_informant.middleware" do |app|
20
+ next unless RailsInformant.capture_errors
21
+
22
+ app.middleware.insert_before ActionDispatch::ShowExceptions,
23
+ RailsInformant::Middleware::ErrorCapture
24
+ app.middleware.insert_after ActionDispatch::DebugExceptions,
25
+ RailsInformant::Middleware::RescuedExceptionInterceptor
26
+ end
27
+
28
+ initializer "rails_informant.breadcrumbs" do
29
+ next unless RailsInformant.capture_errors
30
+
31
+ RailsInformant::BreadcrumbSubscriber.subscribe!
32
+ end
33
+
34
+ initializer "rails_informant.structured_events" do
35
+ next unless RailsInformant.capture_errors
36
+
37
+ Rails.event.subscribe(
38
+ RailsInformant::StructuredEventSubscriber.new
39
+ )
40
+ end
41
+
42
+ initializer "rails_informant.detect_deploy" do
43
+ config.after_initialize do
44
+ next unless RailsInformant.server_mode?
45
+
46
+ current_sha = RailsInformant.current_git_sha
47
+ next unless current_sha
48
+
49
+ now = Time.current
50
+ RailsInformant::ErrorGroup
51
+ .where(status: "fix_pending")
52
+ .where.not(original_sha: current_sha)
53
+ .in_batches(of: 100)
54
+ .update_all(status: "resolved", resolved_at: now, fix_deployed_at: now, updated_at: now)
55
+ rescue ActiveRecord::StatementInvalid
56
+ # Table may not exist yet during initial migration
57
+ end
58
+ end
59
+
60
+ initializer "rails_informant.validate_api_token" do
61
+ config.after_initialize { RailsInformant::Engine.validate_api_token! }
62
+ end
63
+
64
+ MINIMUM_TOKEN_LENGTH = 32
65
+
66
+ def self.validate_api_token!
67
+ return unless RailsInformant.capture_errors
68
+
69
+ token = RailsInformant.api_token
70
+
71
+ if token.nil?
72
+ raise <<~MSG.squish
73
+ RailsInformant: api_token must be configured when capture_errors is enabled.
74
+ Set it in your initializer: config.api_token = "your-secret-token"
75
+ MSG
76
+ end
77
+
78
+ if token.length < MINIMUM_TOKEN_LENGTH
79
+ raise <<~MSG.squish
80
+ RailsInformant: api_token must be at least #{MINIMUM_TOKEN_LENGTH} characters.
81
+ Use SecureRandom.hex(32) or Rails credentials to generate a secure token.
82
+ MSG
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,47 @@
1
+ module RailsInformant
2
+ class ErrorRecorder
3
+ MAX_OCCURRENCES_PER_GROUP = 25
4
+ OCCURRENCE_COOLDOWN = 5 # seconds
5
+
6
+ class << self
7
+ def record(error, severity: "error", context: {}, source: nil, env: nil)
8
+ return unless RailsInformant.initialized?
9
+ now = Time.current
10
+ attrs = ContextBuilder.group_attributes(error, severity:, context:, env:, now:)
11
+ group = ErrorGroup.find_or_create_for(Fingerprint.generate(error), attrs)
12
+ group.detect_regression!
13
+ store_occurrence(group, error, env:, context:) if should_store_occurrence?(group)
14
+ notify(group)
15
+ rescue StandardError => e
16
+ Rails.logger.error "[RailsInformant] Capture failed: #{e.class}: #{e.message}\n#{e.backtrace&.first(5)&.join("\n")}"
17
+ end
18
+
19
+ private
20
+
21
+ def should_store_occurrence?(group)
22
+ return true if group.total_occurrences <= 1
23
+ return true unless group.last_occurrence_stored_at
24
+ group.last_occurrence_stored_at.before?(OCCURRENCE_COOLDOWN.seconds.ago)
25
+ end
26
+
27
+ def store_occurrence(group, error, env:, context:)
28
+ now = Time.current
29
+ ErrorGroup.transaction do
30
+ group.occurrences.create! ContextBuilder.occurrence_attributes(error, env:, context:)
31
+ ErrorGroup.where(id: group.id).update_all last_occurrence_stored_at: now, updated_at: now
32
+ group.last_occurrence_stored_at = now
33
+ end
34
+ trim_occurrences(group) if group.total_occurrences > MAX_OCCURRENCES_PER_GROUP
35
+ end
36
+
37
+ def trim_occurrences(group)
38
+ keep_ids = group.occurrences.order(created_at: :desc).limit(MAX_OCCURRENCES_PER_GROUP).select(:id)
39
+ Occurrence.where(error_group_id: group.id).where.not(id: keep_ids).delete_all
40
+ end
41
+ def notify(group)
42
+ return unless RailsInformant.config.notifiers.any? { it.should_notify?(group) }
43
+ RailsInformant::NotifyJob.perform_later group
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,17 @@
1
+ module RailsInformant
2
+ class ErrorSubscriber
3
+ SKIP_SOURCES = /_cache_store\.active_support\z/
4
+
5
+ def report(error, handled:, severity:, context:, source: nil)
6
+ return unless RailsInformant.initialized?
7
+ return if handled
8
+ return if source && SKIP_SOURCES.match?(source)
9
+ return if RailsInformant.ignored_exception?(error)
10
+ return if RailsInformant.already_captured?(error)
11
+
12
+ RailsInformant.mark_captured!(error)
13
+
14
+ ErrorRecorder.record error, severity:, context:, source:
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,23 @@
1
+ require "digest"
2
+
3
+ module RailsInformant
4
+ class Fingerprint
5
+ APP_FRAME_PATTERN = /\A(?!.*\/(gems|ruby|rubies)\/)/.freeze
6
+
7
+ def self.generate(exception)
8
+ return Digest::SHA256.hexdigest(exception.class.name) unless exception.backtrace
9
+
10
+ first_frame = first_app_frame(exception)
11
+ normalized = normalize_frame(first_frame)
12
+ Digest::SHA256.hexdigest "#{exception.class.name}:#{normalized}"
13
+ end
14
+
15
+ def self.first_app_frame(exception)
16
+ exception.backtrace.find { APP_FRAME_PATTERN.match?(it) } || exception.backtrace.first
17
+ end
18
+
19
+ def self.normalize_frame(frame)
20
+ frame.sub(/:(\d+)(?=:in |$)/, ":0")
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,38 @@
1
+ module RailsInformant
2
+ module Mcp
3
+ class BaseTool < ::MCP::Tool
4
+ class << self
5
+ private
6
+
7
+ def client_for(server_context:, environment: nil)
8
+ config = server_context[:config]
9
+ env = environment || config.default_environment
10
+ config.client_for(env)
11
+ end
12
+
13
+ def with_client(server_context:, environment: nil)
14
+ client = client_for(server_context:, environment:)
15
+ yield client
16
+ rescue Client::Error => e
17
+ error_response(e.message)
18
+ end
19
+
20
+ def text_response(data)
21
+ text = data.is_a?(String) ? data : JSON.generate(data)
22
+ ::MCP::Tool::Response.new([ { type: "text", text: } ])
23
+ end
24
+
25
+ def error_response(message)
26
+ ::MCP::Tool::Response.new([ { type: "text", text: message } ], error: true)
27
+ end
28
+
29
+ def paginated_text_response(result)
30
+ response_text = JSON.generate(result["data"])
31
+ meta = result["meta"]
32
+ response_text += "\n\nPage #{meta["page"]}, per_page: #{meta["per_page"]}, has_more: #{meta["has_more"]}" if meta
33
+ text_response(response_text)
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,123 @@
1
+ require "net/http"
2
+ require "json"
3
+ require "uri"
4
+
5
+ module RailsInformant
6
+ module Mcp
7
+ class Client
8
+ Error = Class.new(StandardError)
9
+
10
+ METHODS = {
11
+ delete: Net::HTTP::Delete,
12
+ get: Net::HTTP::Get,
13
+ patch: Net::HTTP::Patch,
14
+ post: Net::HTTP::Post
15
+ }.freeze
16
+
17
+ def initialize(url:, token:, allow_insecure: false, path_prefix: "/informant")
18
+ @base_url = url.chomp("/")
19
+ @token = token
20
+ @allow_insecure = allow_insecure
21
+ @path_prefix = path_prefix.chomp("/")
22
+ @_base_uri = URI.parse(@base_url)
23
+ validate_url!
24
+ end
25
+
26
+ def list_errors(status: nil, error_class: nil, q: nil, since: nil, page: nil, per_page: nil, controller_action: nil, job_class: nil, severity: nil, **extra)
27
+ params = { controller_action:, error_class:, job_class:, q:, severity:, since:, status:, page:, per_page: }
28
+ params[:until] = extra[:until] if extra[:until]
29
+ perform :get, "#{@path_prefix}/api/v1/errors", params: params.compact
30
+ end
31
+
32
+ def get_error(id)
33
+ perform :get, "#{@path_prefix}/api/v1/errors/#{Integer(id)}"
34
+ end
35
+
36
+ def update_error(id, params)
37
+ perform :patch, "#{@path_prefix}/api/v1/errors/#{Integer(id)}", body: params
38
+ end
39
+
40
+ def delete_error(id)
41
+ perform :delete, "#{@path_prefix}/api/v1/errors/#{Integer(id)}"
42
+ end
43
+
44
+ def fix_pending(id, fix_sha:, original_sha:, fix_pr_url: nil)
45
+ perform :patch, "#{@path_prefix}/api/v1/errors/#{Integer(id)}/fix_pending", body: { fix_sha:, original_sha:, fix_pr_url: }.compact
46
+ end
47
+
48
+ def mark_duplicate(id, duplicate_of_id:)
49
+ perform :patch, "#{@path_prefix}/api/v1/errors/#{Integer(id)}/duplicate", body: { duplicate_of_id: }
50
+ end
51
+
52
+ def list_occurrences(error_group_id: nil, since: nil, page: nil, per_page: nil, **extra)
53
+ params = { error_group_id:, since:, page:, per_page: }
54
+ params[:until] = extra[:until] if extra[:until]
55
+ perform :get, "#{@path_prefix}/api/v1/occurrences", params: params.compact
56
+ end
57
+
58
+ def status
59
+ perform :get, "#{@path_prefix}/api/v1/status"
60
+ end
61
+
62
+ private
63
+
64
+ def validate_url!
65
+ return if @allow_insecure
66
+ raise Error, "HTTPS required. Use --allow-insecure for local development." unless @_base_uri.scheme == "https"
67
+ end
68
+
69
+ def perform(method, path, params: {}, body: nil)
70
+ uri = build_uri(path, params)
71
+ klass = METHODS.fetch(method) { raise ArgumentError, "Unsupported HTTP method: #{method}" }
72
+ req = klass.new(uri)
73
+ if body
74
+ req.body = JSON.generate(body)
75
+ req["Content-Type"] = "application/json"
76
+ end
77
+ execute(req)
78
+ end
79
+
80
+ def build_uri(path, params = {})
81
+ uri = URI.parse("#{@base_url}#{path}")
82
+ uri.query = URI.encode_www_form(params) unless params.empty?
83
+ uri
84
+ end
85
+
86
+ def connection
87
+ @_connection ||= begin
88
+ http = Net::HTTP.new(@_base_uri.host, @_base_uri.port)
89
+ http.use_ssl = @_base_uri.scheme == "https"
90
+ http.open_timeout = 5
91
+ http.read_timeout = 10
92
+ http.start
93
+ end
94
+ end
95
+
96
+ def execute(req)
97
+ req["Authorization"] = "Bearer #{@token}"
98
+
99
+ response = connection.request(req)
100
+
101
+ case response
102
+ when Net::HTTPSuccess
103
+ response.body&.empty? ? nil : JSON.parse(response.body)
104
+ when Net::HTTPUnauthorized
105
+ raise Error, "Authentication failed. Check your API token."
106
+ when Net::HTTPNotFound
107
+ raise Error, "Not found (404)"
108
+ else
109
+ body = JSON.parse(response.body)
110
+ raise Error, body["error"] || "HTTP #{response.code}"
111
+ end
112
+ rescue JSON::ParserError
113
+ raise Error, "HTTP #{response.code}: #{response.body.to_s[0, 200]}"
114
+ rescue Errno::ECONNREFUSED, Errno::ECONNRESET, Errno::EHOSTUNREACH, SocketError => e
115
+ @_connection = nil
116
+ raise Error, "Connection failed: #{e.message}"
117
+ rescue Net::OpenTimeout, Net::ReadTimeout => e
118
+ @_connection = nil
119
+ raise Error, "Request timed out: #{e.message}"
120
+ end
121
+ end
122
+ end
123
+ end