bugsage 0.2.0

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 (69) hide show
  1. checksums.yaml +7 -0
  2. data/ARCHITECTURE.md +442 -0
  3. data/CHANGELOG.md +28 -0
  4. data/CODE_OF_CONDUCT.md +10 -0
  5. data/CONTRIBUTING.md +301 -0
  6. data/LICENSE.txt +21 -0
  7. data/README.md +344 -0
  8. data/ROADMAP.md +217 -0
  9. data/SECURITY.md +83 -0
  10. data/docs/AI.md +167 -0
  11. data/docs/Configuration.md +168 -0
  12. data/docs/GettingStarted.md +146 -0
  13. data/docs/RELEASE_CHECKLIST.md +346 -0
  14. data/docs/Troubleshooting.md +181 -0
  15. data/docs/images/BadRequest.png +0 -0
  16. data/docs/images/BadRequest2.png +0 -0
  17. data/docs/images/BugSage_Logo.png +0 -0
  18. data/docs/images/BugSage_Social_Preview.png +0 -0
  19. data/docs/images/NoMethodError.png +0 -0
  20. data/docs/images/NoMethodError2.png +0 -0
  21. data/docs/images/README.md +38 -0
  22. data/docs/releases/README.md +21 -0
  23. data/docs/releases/v0.2.0.md +40 -0
  24. data/exe/bugsage +7 -0
  25. data/lib/bugsage/ai_analyzer.rb +145 -0
  26. data/lib/bugsage/ai_chat.rb +388 -0
  27. data/lib/bugsage/ai_context.rb +69 -0
  28. data/lib/bugsage/ai_panel.rb +708 -0
  29. data/lib/bugsage/ai_support.rb +36 -0
  30. data/lib/bugsage/auto_configurator.rb +97 -0
  31. data/lib/bugsage/cli.rb +59 -0
  32. data/lib/bugsage/code_context.rb +81 -0
  33. data/lib/bugsage/code_patch.rb +147 -0
  34. data/lib/bugsage/configuration.rb +149 -0
  35. data/lib/bugsage/console_context.rb +51 -0
  36. data/lib/bugsage/cursor_client.rb +165 -0
  37. data/lib/bugsage/dashboard.rb +627 -0
  38. data/lib/bugsage/editor_links.rb +34 -0
  39. data/lib/bugsage/error_page.rb +298 -0
  40. data/lib/bugsage/exception_handler.rb +66 -0
  41. data/lib/bugsage/exception_support.rb +95 -0
  42. data/lib/bugsage/exceptions_app.rb +31 -0
  43. data/lib/bugsage/fix_applicator.rb +107 -0
  44. data/lib/bugsage/formatter.rb +37 -0
  45. data/lib/bugsage/http_error_capture.rb +104 -0
  46. data/lib/bugsage/http_response_error.rb +14 -0
  47. data/lib/bugsage/inline_console.rb +226 -0
  48. data/lib/bugsage/installation.rb +94 -0
  49. data/lib/bugsage/installer.rb +66 -0
  50. data/lib/bugsage/json_endpoint.rb +34 -0
  51. data/lib/bugsage/locales/en.yml +439 -0
  52. data/lib/bugsage/middleware.rb +152 -0
  53. data/lib/bugsage/openai_client.rb +103 -0
  54. data/lib/bugsage/page_actions.rb +255 -0
  55. data/lib/bugsage/railtie.rb +49 -0
  56. data/lib/bugsage/request_context.rb +56 -0
  57. data/lib/bugsage/rule.rb +271 -0
  58. data/lib/bugsage/session_clear.rb +35 -0
  59. data/lib/bugsage/store.rb +60 -0
  60. data/lib/bugsage/suggestion.rb +58 -0
  61. data/lib/bugsage/trace_cleaner.rb +24 -0
  62. data/lib/bugsage/translations.rb +85 -0
  63. data/lib/bugsage/version.rb +5 -0
  64. data/lib/bugsage.rb +65 -0
  65. data/lib/generators/bugsage/install/install_generator.rb +21 -0
  66. data/lib/generators/bugsage/install/templates/bugsage.rb +22 -0
  67. data/scripts/publish-github-release.sh +38 -0
  68. data/sig/bugsage.rbs +4 -0
  69. metadata +157 -0
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Bugsage
6
+ module HttpErrorCapture
7
+ module_function
8
+
9
+ def capture?(status, env)
10
+ return false unless status.to_i >= 400
11
+ return false if bugsage_path?(env)
12
+ return false if ExceptionSupport.extract(env)
13
+
14
+ true
15
+ end
16
+
17
+ def build_exception(env, status, body)
18
+ body_text = body.to_s.strip
19
+ location = location_for(env)
20
+ message = build_message(status, body_text, location)
21
+
22
+ HttpResponseError.new(
23
+ status: status.to_i,
24
+ message: message,
25
+ response_body: body_text.empty? ? nil : body_text,
26
+ location: location
27
+ )
28
+ end
29
+
30
+ def location_for(env)
31
+ params = env["action_dispatch.request.path_parameters"]
32
+ return Bugsage.t("common.unknown") unless params.is_a?(Hash)
33
+
34
+ controller = params[:controller] || params["controller"]
35
+ action = params[:action] || params["action"]
36
+ return Bugsage.t("common.unknown") if controller.to_s.strip.empty?
37
+
38
+ "#{format_controller_name(controller)}Controller##{action}"
39
+ end
40
+
41
+ def format_controller_name(controller)
42
+ controller.to_s.split("/").map do |segment|
43
+ segment.split("_").map(&:capitalize).join
44
+ end.join("::")
45
+ end
46
+
47
+ def build_message(status, body, location)
48
+ status_label = http_status_label(status)
49
+ detail = extract_detail(body)
50
+ base = Bugsage.t("http_errors.message.base", status: status, status_label: status_label)
51
+ unknown = Bugsage.t("common.unknown")
52
+ base += Bugsage.t("http_errors.message.at_location", location: location) unless location == unknown
53
+ detail.empty? ? base : "#{base}#{Bugsage.t("http_errors.message.with_detail", detail: detail)}"
54
+ end
55
+
56
+ def extract_detail(body)
57
+ return "" if body.to_s.strip.empty?
58
+
59
+ payload = JSON.parse(body)
60
+ return body.strip unless payload.is_a?(Hash)
61
+
62
+ detail = payload["error"] || payload["message"] || payload["errors"]
63
+ case detail
64
+ when String
65
+ detail.strip
66
+ when Hash
67
+ detail.map { |key, value| "#{key}: #{value}" }.join(", ")
68
+ when Array
69
+ detail.join(", ")
70
+ else
71
+ body.strip
72
+ end
73
+ rescue JSON::ParserError
74
+ body.strip
75
+ end
76
+
77
+ def bugsage_path?(env)
78
+ path = env["PATH_INFO"].to_s
79
+ path.start_with?("/bugsage")
80
+ end
81
+
82
+ def http_status_label(status)
83
+ if defined?(Rack::Utils)
84
+ Rack::Utils::HTTP_STATUS_CODES[status.to_i] || Bugsage.t("http_errors.status_label_fallback", status: status)
85
+ else
86
+ Bugsage.t("http_errors.status_label_fallback", status: status)
87
+ end
88
+ end
89
+
90
+ def fixes_for_status(status)
91
+ fixes = Bugsage.t("http_errors.fixes.#{status.to_i}", default: nil)
92
+ fixes = Bugsage.t("http_errors.fixes.default", default: nil) if fixes.nil?
93
+ Array(fixes)
94
+ end
95
+
96
+ def confidence_for_status(status)
97
+ case status.to_i
98
+ when 400, 401, 422 then 86
99
+ when 403, 404 then 84
100
+ else 80
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bugsage
4
+ class HttpResponseError < StandardError
5
+ attr_reader :status, :response_body, :location
6
+
7
+ def initialize(status:, message:, response_body: nil, location: nil)
8
+ @status = status
9
+ @response_body = response_body
10
+ @location = location
11
+ super(message)
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,226 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Bugsage
6
+ class InlineConsole
7
+ extend JsonEndpoint
8
+
9
+ def self.evaluate(code)
10
+ return error_response("Console is not available for this error.") unless ConsoleContext.available?
11
+
12
+ stripped = code.to_s.strip
13
+ return error_response("Enter a Ruby expression to evaluate.") if stripped.empty?
14
+
15
+ binding = ConsoleContext.binding_for_eval
16
+ result = eval(stripped, binding, "(bugsage-console)", 1) # rubocop:disable Security/Eval
17
+
18
+ success_response(result)
19
+ rescue SyntaxError, StandardError => e
20
+ error_response(format_exception(e))
21
+ end
22
+
23
+ def self.handle_request(env)
24
+ return not_found unless Bugsage.configuration.show_inline_console?
25
+
26
+ payload = parse_request_body(env)
27
+ return json_response(error_response("Console is not available for this error.")) unless prepare_context!(payload)
28
+
29
+ response = evaluate(payload["code"])
30
+ json_response(response)
31
+ end
32
+
33
+ def self.prepare_context!(payload)
34
+ return true unless payload.key?("index")
35
+
36
+ load_context_for_index(payload["index"])
37
+ end
38
+
39
+ def self.load_context_for_index(index)
40
+ event = Store.all[index.to_i]
41
+ ConsoleContext.load_from_event(event)
42
+ end
43
+
44
+ def self.render_panel(bug_index: nil, include_script: true)
45
+ return "" unless Bugsage.configuration.show_inline_console?
46
+
47
+ suffix = bug_index.nil? ? "" : "-bug-#{bug_index}"
48
+ output_id = "bugsage-console-output#{suffix}"
49
+ input_id = "bugsage-console-input#{suffix}"
50
+ index_attr = bug_index.nil? ? "" : %( data-bug-index="#{bug_index}")
51
+
52
+ <<~HTML
53
+ <div class="section inline-console">
54
+ <div class="label">Inline Rails Console</div>
55
+ <p class="console-help">
56
+ Evaluate Ruby in the context of this error. Available locals:
57
+ <code>exception</code>, <code>request_context</code>, <code>params</code>
58
+ </p>
59
+ <form
60
+ class="console-form bugsage-console-form"
61
+ data-output-target="#{output_id}"
62
+ data-input-target="#{input_id}"#{index_attr}
63
+ >
64
+ <label class="console-prompt" for="#{input_id}">&gt;&gt;</label>
65
+ <input
66
+ id="#{input_id}"
67
+ class="console-input"
68
+ type="text"
69
+ name="code"
70
+ autocomplete="off"
71
+ spellcheck="false"
72
+ placeholder="exception.class"
73
+ />
74
+ <button type="submit" class="console-submit">Run</button>
75
+ </form>
76
+ <div id="#{output_id}" class="console-output" aria-live="polite"></div>
77
+ </div>
78
+ #{render_script if include_script}
79
+ HTML
80
+ end
81
+
82
+ def self.styles
83
+ <<~CSS
84
+ .inline-console {
85
+ background: #313244;
86
+ border: 1px solid #45475a;
87
+ border-radius: 8px;
88
+ padding: 16px;
89
+ margin-bottom: 20px;
90
+ }
91
+ .console-help {
92
+ color: #a6adc8;
93
+ font-size: 13px;
94
+ margin: 0 0 12px 0;
95
+ }
96
+ .console-help code {
97
+ color: #89b4fa;
98
+ }
99
+ .console-output {
100
+ background: #1e1e2e;
101
+ border: 1px solid #45475a;
102
+ border-radius: 6px;
103
+ min-height: 72px;
104
+ max-height: 260px;
105
+ overflow-y: auto;
106
+ padding: 12px;
107
+ margin-top: 12px;
108
+ }
109
+ .console-output:empty {
110
+ display: none;
111
+ }
112
+ .console-line {
113
+ white-space: pre-wrap;
114
+ margin-bottom: 6px;
115
+ font-size: 13px;
116
+ }
117
+ .console-line.input { color: #cdd6f4; }
118
+ .console-line.result { color: #a6e3a1; }
119
+ .console-line.error { color: #f38ba8; }
120
+ .console-form {
121
+ display: flex;
122
+ align-items: center;
123
+ gap: 8px;
124
+ }
125
+ .console-prompt {
126
+ color: #a6e3a1;
127
+ font-weight: bold;
128
+ }
129
+ .console-input {
130
+ flex: 1;
131
+ background: #1e1e2e;
132
+ color: #cdd6f4;
133
+ border: 1px solid #45475a;
134
+ border-radius: 6px;
135
+ padding: 10px 12px;
136
+ font-family: inherit;
137
+ font-size: 13px;
138
+ }
139
+ .console-input:focus {
140
+ outline: none;
141
+ border-color: #89b4fa;
142
+ }
143
+ .console-submit {
144
+ background: #89b4fa;
145
+ color: #1e1e2e;
146
+ border: 0;
147
+ border-radius: 6px;
148
+ padding: 10px 14px;
149
+ font-family: inherit;
150
+ font-size: 13px;
151
+ font-weight: bold;
152
+ cursor: pointer;
153
+ }
154
+ CSS
155
+ end
156
+
157
+ def self.render_script
158
+ <<~HTML
159
+ <script>
160
+ (function () {
161
+ if (window.__bugsageConsoleInitialized) return;
162
+ window.__bugsageConsoleInitialized = true;
163
+
164
+ function appendLine(output, text, type) {
165
+ var line = document.createElement("div");
166
+ line.className = "console-line" + (type ? " " + type : "");
167
+ line.textContent = text;
168
+ output.appendChild(line);
169
+ output.scrollTop = output.scrollHeight;
170
+ }
171
+
172
+ document.addEventListener("submit", function (event) {
173
+ var form = event.target;
174
+ if (!form.classList || !form.classList.contains("bugsage-console-form")) return;
175
+
176
+ event.preventDefault();
177
+ var input = document.getElementById(form.dataset.inputTarget);
178
+ var output = document.getElementById(form.dataset.outputTarget);
179
+ if (!input || !output) return;
180
+
181
+ var code = input.value.trim();
182
+ if (!code) return;
183
+
184
+ appendLine(output, ">> " + code, "input");
185
+ input.value = "";
186
+
187
+ var payload = { code: code };
188
+ if (form.dataset.bugIndex !== undefined) {
189
+ payload.index = parseInt(form.dataset.bugIndex, 10);
190
+ }
191
+
192
+ fetch("/bugsage/console", {
193
+ method: "POST",
194
+ headers: { "Content-Type": "application/json" },
195
+ body: JSON.stringify(payload)
196
+ })
197
+ .then(function (response) { return response.json(); })
198
+ .then(function (result) {
199
+ appendLine(output, result.output, result.ok ? "result" : "error");
200
+ })
201
+ .catch(function (error) {
202
+ appendLine(output, error.message, "error");
203
+ });
204
+ });
205
+ })();
206
+ </script>
207
+ HTML
208
+ end
209
+
210
+ def self.success_response(result)
211
+ { ok: true, output: "=> #{format_value(result)}" }
212
+ end
213
+
214
+ def self.error_response(message)
215
+ { ok: false, output: message.to_s }
216
+ end
217
+
218
+ def self.format_value(value)
219
+ value.inspect
220
+ end
221
+
222
+ def self.format_exception(exception)
223
+ "#{exception.class}: #{exception.message}"
224
+ end
225
+ end
226
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bugsage
4
+ # Single source of truth for installing BugSage in a Rails application.
5
+ # Referenced by the README, install generator, CLI, and initializer template.
6
+ module Installation
7
+ STEPS = [
8
+ {
9
+ title: "Add the gem to your Gemfile",
10
+ command: 'gem "bugsage"',
11
+ note: "Use a git/path source while developing locally if needed."
12
+ },
13
+ {
14
+ title: "Install dependencies",
15
+ command: "bundle install",
16
+ note: nil
17
+ },
18
+ {
19
+ title: "Start your Rails server",
20
+ command: "bin/rails server",
21
+ note: "BugSage auto-wires via Bugsage::Railtie — no routes.rb or middleware changes required."
22
+ },
23
+ {
24
+ title: "Trigger an error to verify",
25
+ command: "Visit any route that raises an exception in development.",
26
+ note: "You should see the BugSage error page instead of the default Rails page."
27
+ },
28
+ {
29
+ title: "Open the session dashboard (optional)",
30
+ command: "http://localhost:3000/bugsage",
31
+ note: "Lists all errors caught during the current server session."
32
+ }
33
+ ].freeze
34
+
35
+ OPTIONAL_STEPS = [
36
+ {
37
+ title: "Generate a commented initializer for custom overrides",
38
+ command: "bundle exec rails generate bugsage:install",
39
+ alternative: "bundle exec bugsage install"
40
+ },
41
+ {
42
+ title: "Enable AI-enhanced suggestions (optional)",
43
+ command: "export OPENAI_API_KEY=sk-your-key",
44
+ alternative: "export CURSOR_API_KEY=crsr_your-key",
45
+ note: "BugSage auto-detects the provider from the key prefix on boot."
46
+ },
47
+ {
48
+ title: "Control enabled environments (optional)",
49
+ command: "export BUGSAGE_ENABLED_ENVIRONMENTS=development,test,staging",
50
+ note: "Defaults to development and test."
51
+ }
52
+ ].freeze
53
+
54
+ AUTO_WIRED = [
55
+ "Exception capture middleware",
56
+ "BugSage HTML error pages in development",
57
+ "Session dashboard at /bugsage",
58
+ "Inline Rails console at /bugsage/console",
59
+ "Routing error capture via exceptions_app",
60
+ "AI provider auto-detection when API keys are present"
61
+ ].freeze
62
+
63
+ module_function
64
+
65
+ def guide_lines
66
+ lines = ["BugSage — Rails installation steps", ""]
67
+
68
+ STEPS.each_with_index do |step, index|
69
+ lines << "#{index + 1}. #{step[:title]}"
70
+ lines << " #{step[:command]}"
71
+ lines << " #{step[:note]}" if step[:note]
72
+ lines << ""
73
+ end
74
+
75
+ lines << "Optional:"
76
+ OPTIONAL_STEPS.each_with_index do |step, index|
77
+ lines << "#{index + 1}. #{step[:title]}"
78
+ lines << " #{step[:command]}"
79
+ lines << " or: #{step[:alternative]}" if step[:alternative]
80
+ lines << " #{step[:note]}" if step[:note]
81
+ lines << ""
82
+ end
83
+
84
+ lines << "Auto-wired on boot (no manual setup):"
85
+ AUTO_WIRED.each { |item| lines << " - #{item}" }
86
+
87
+ lines
88
+ end
89
+
90
+ def print_guide
91
+ guide_lines.each { |line| puts line }
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Bugsage
6
+ class Installer
7
+ INITIALIZER_PATH = "config/initializers/bugsage.rb"
8
+ TEMPLATE_PATH = File.expand_path("../../generators/bugsage/install/templates/bugsage.rb", __dir__)
9
+
10
+ def self.run(destination: Dir.pwd, force: false)
11
+ new(destination: destination, force: force).run
12
+ end
13
+
14
+ def initialize(destination:, force: false)
15
+ @destination = File.expand_path(destination)
16
+ @force = force
17
+ end
18
+
19
+ def run
20
+ validate_rails_app!
21
+
22
+ path = File.join(@destination, INITIALIZER_PATH)
23
+ created_initializer = write_initializer(path)
24
+ config = Bugsage::AutoConfigurator.apply!
25
+
26
+ {
27
+ initializer_path: path,
28
+ created_initializer: created_initializer,
29
+ summary: Bugsage::AutoConfigurator.summary(config)
30
+ }
31
+ end
32
+
33
+ private
34
+
35
+ def validate_rails_app!
36
+ application_file = File.join(@destination, "config/application.rb")
37
+ return if File.exist?(application_file)
38
+
39
+ raise Error, "Could not find a Rails app at #{@destination}. Run this from your app root."
40
+ end
41
+
42
+ def write_initializer(path)
43
+ return false if File.exist?(path) && !@force
44
+
45
+ FileUtils.mkdir_p(File.dirname(path))
46
+ File.write(path, initializer_template)
47
+ true
48
+ end
49
+
50
+ def initializer_template
51
+ if File.exist?(TEMPLATE_PATH)
52
+ File.read(TEMPLATE_PATH)
53
+ else
54
+ default_initializer_template
55
+ end
56
+ end
57
+
58
+ def default_initializer_template
59
+ <<~RUBY
60
+ # frozen_string_literal: true
61
+
62
+ # See Bugsage::Installation or run: bundle exec bugsage install --guide-only
63
+ RUBY
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Bugsage
6
+ # Shared Rack JSON helpers for BugSage HTTP endpoints.
7
+ # Extend on endpoint classes: `extend JsonEndpoint`
8
+ module JsonEndpoint
9
+ def parse_request_body(env)
10
+ body = env["rack.input"]
11
+ raw = body.respond_to?(:read) ? body.read : body.to_s
12
+ body.rewind if body.respond_to?(:rewind)
13
+
14
+ return {} if raw.to_s.strip.empty?
15
+
16
+ JSON.parse(raw)
17
+ rescue JSON::ParserError
18
+ {}
19
+ end
20
+
21
+ def json_response(payload = nil, status: 200, **attrs)
22
+ body = payload.nil? ? attrs : payload
23
+ [status, { "Content-Type" => "application/json" }, [JSON.generate(body)]]
24
+ end
25
+
26
+ def error_response(message)
27
+ { ok: false, error: message.to_s }
28
+ end
29
+
30
+ def not_found
31
+ [404, { "Content-Type" => "text/plain" }, [Bugsage.t("common.not_found")]]
32
+ end
33
+ end
34
+ end