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,439 @@
1
+ en:
2
+ bugsage:
3
+ common:
4
+ unknown: "unknown"
5
+ not_available: "n/a"
6
+ not_found: "Not Found"
7
+
8
+ context:
9
+ request_method: "Request method"
10
+ path: "Path"
11
+ query_string: "Query string"
12
+ host: "Host"
13
+ request_id: "Request ID"
14
+ controller: "Controller"
15
+ action: "Action"
16
+ path_parameters: "Path parameters"
17
+ request_parameters: "Request parameters"
18
+ query_parameters: "Query parameters"
19
+ form_parameters: "Form parameters"
20
+ user_agent: "User agent"
21
+
22
+ rules:
23
+ no_method_error:
24
+ issue: "NoMethodError"
25
+ root_cause_default: "Called a method on an unexpected object"
26
+ fixes:
27
+ - "Check object initialization"
28
+ - "Add nil guard"
29
+ - "Verify the method exists on the receiver"
30
+
31
+ record_not_found:
32
+ issue: "ActiveRecord::RecordNotFound"
33
+ fixes:
34
+ - "Verify the record ID exists before querying"
35
+ - "Use find_by instead of find if a missing record is expected"
36
+ - "Add a rescue_from ActiveRecord::RecordNotFound handler for a friendly 404 page"
37
+
38
+ routing_error:
39
+ issue: "ActionController::RoutingError"
40
+ fixes:
41
+ - "Verify the requested route and path"
42
+ - "Check the controller and action names"
43
+ - "Confirm the route is defined in config/routes.rb"
44
+
45
+ parameter_missing:
46
+ issue: "ActionController::ParameterMissing"
47
+ fixes:
48
+ - "Check the incoming parameters and required fields"
49
+ - "Ensure the expected form or query parameter is present"
50
+ - "Add a fallback or validation before accessing the parameter"
51
+
52
+ unpermitted_parameters:
53
+ issue: "ActionController::UnpermittedParameters"
54
+ fixes:
55
+ - "Whitelist allowed parameters in your strong params"
56
+ - "Review the incoming payload shape"
57
+ - "Match the parameter names expected by your controller"
58
+
59
+ bad_request:
60
+ issue: "ActionController::BadRequest"
61
+ fixes:
62
+ - "Verify the request payload and expected format"
63
+ - "Check parameter names and content types"
64
+ - "Add a rescue handler for malformed requests"
65
+
66
+ invalid_authenticity_token:
67
+ issue: "ActionController::InvalidAuthenticityToken"
68
+ fixes:
69
+ - "Ensure the form includes the CSRF token"
70
+ - "Review token generation and session persistence"
71
+ - "Verify that the request is coming from the expected origin"
72
+
73
+ parse_error:
74
+ issue: "ActionDispatch::Http::Parameters::ParseError"
75
+ fixes:
76
+ - "Verify the request body format and encoding"
77
+ - "Inspect the client payload being sent"
78
+ - "Ensure the server accepts the expected content type"
79
+
80
+ deserialization_error:
81
+ issue: "ActiveJob::DeserializationError"
82
+ fixes:
83
+ - "Check job payload compatibility and serializer setup"
84
+ - "Ensure referenced classes are still present"
85
+ - "Review recent model or job changes that could break deserialization"
86
+
87
+ template_error:
88
+ issue: "ActionView::Template::Error"
89
+ fixes:
90
+ - "Verify the template name and format"
91
+ - "Check the view file exists and uses the expected layout"
92
+ - "Review partials or helpers referenced by the view"
93
+
94
+ missing_exact_template:
95
+ issue: "ActionController::MissingExactTemplate"
96
+ fixes:
97
+ - "Add the expected template for the requested format"
98
+ - "Check the request format and route constraints"
99
+ - "Ensure the appropriate template exists for the action"
100
+
101
+ active_storage_error:
102
+ issue: "ActiveStorage::FileNotFoundError"
103
+ fixes:
104
+ - "Verify the storage object exists and the key is correct"
105
+ - "Check the file upload or attachment lifecycle"
106
+ - "Ensure the backing storage service is reachable"
107
+
108
+ active_storage_integrity_error:
109
+ issue: "ActiveStorage::IntegrityError"
110
+ fixes:
111
+ - "Validate uploaded content and storage integrity"
112
+ - "Check for partial uploads or corrupted blobs"
113
+ - "Review the storage backend and file transfer path"
114
+
115
+ redis_error:
116
+ issue: "Redis::BaseError"
117
+ fixes:
118
+ - "Verify the Redis connection settings and service availability"
119
+ - "Check authentication credentials and network access"
120
+ - "Inspect the Redis client configuration and timeouts"
121
+
122
+ unknown_format:
123
+ issue: "ActionController::UnknownFormat"
124
+ fixes:
125
+ - "Check the requested format and respond with a supported one"
126
+ - "Verify the request format or content negotiation logic"
127
+ - "Add a fallback response for unsupported formats"
128
+
129
+ invalid_cross_origin_request:
130
+ issue: "ActionController::InvalidCrossOriginRequest"
131
+ fixes:
132
+ - "Verify the CORS configuration and request origin"
133
+ - "Check the allowed methods, headers, and credentials settings"
134
+ - "Review browser preflight requests against your policy"
135
+
136
+ flash_error:
137
+ issue: "ActionDispatch::Flash::FlashError"
138
+ fixes:
139
+ - "Check flash usage and session persistence"
140
+ - "Ensure the session store is available"
141
+ - "Review code that writes or reads flash state"
142
+
143
+ invalid_signature:
144
+ issue: "ActiveSupport::MessageVerifier::InvalidSignature"
145
+ fixes:
146
+ - "Verify the signed payload and secret rotation"
147
+ - "Check for tampered or expired tokens"
148
+ - "Review message generation and verification paths"
149
+
150
+ faraday_error:
151
+ issue: "Faraday::Error"
152
+ fixes:
153
+ - "Inspect the upstream service and network connectivity"
154
+ - "Verify request headers, auth, and timeout settings"
155
+ - "Review recent API contract or endpoint changes"
156
+
157
+ record_invalid:
158
+ issue: "ActiveRecord::RecordInvalid"
159
+ fixes:
160
+ - "Check model validations and required attributes"
161
+ - "Inspect the form or payload being saved"
162
+ - "Add explicit validation messages for the user"
163
+
164
+ record_not_unique:
165
+ issue: "ActiveRecord::RecordNotUnique"
166
+ fixes:
167
+ - "Check for a unique index or constraint being violated"
168
+ - "Look up the existing record before inserting a duplicate"
169
+ - "Use find_or_create_by or upsert to avoid duplicate inserts"
170
+
171
+ not_null_violation:
172
+ issue: "ActiveRecord::NotNullViolation"
173
+ fixes:
174
+ - "Provide a value for the NOT NULL column being inserted"
175
+ - "Add a default value or make the column nullable in a migration"
176
+ - "Validate presence of the attribute before saving"
177
+
178
+ statement_invalid:
179
+ issue: "ActiveRecord::StatementInvalid"
180
+ fixes:
181
+ - "Inspect the generated SQL and the underlying database error"
182
+ - "Check for missing columns, tables, or pending migrations"
183
+ - "Verify data types and query arguments match the schema"
184
+
185
+ connection_not_established:
186
+ issue: "ActiveRecord::ConnectionNotEstablished"
187
+ fixes:
188
+ - "Verify the database is running and reachable"
189
+ - "Check database.yml credentials, host, and pool settings"
190
+ - "Ensure the connection pool is not exhausted under load"
191
+
192
+ stale_object:
193
+ issue: "ActiveRecord::StaleObjectError"
194
+ fixes:
195
+ - "Reload the record before retrying the update"
196
+ - "Handle optimistic locking conflicts with a rescue and retry"
197
+ - "Confirm the lock_version column is being tracked correctly"
198
+
199
+ pg_error:
200
+ issue_fallback: "PG::Error"
201
+ fixes:
202
+ - "Inspect the PostgreSQL error detail in the message"
203
+ - "Verify the connection, credentials, and database availability"
204
+ - "Check the query, constraints, and column definitions"
205
+
206
+ name_error:
207
+ issue: "NameError"
208
+ fixes:
209
+ - "Check for a typo in the constant, class, or variable name"
210
+ - "Ensure the referenced class or module is required and loaded"
211
+ - "Verify the name is defined in the current scope"
212
+
213
+ key_error:
214
+ issue: "KeyError"
215
+ fixes:
216
+ - "Verify the key exists before accessing it with fetch"
217
+ - "Provide a default value to Hash#fetch"
218
+ - "Check the source data for the expected keys"
219
+
220
+ argument_error:
221
+ issue: "ArgumentError"
222
+ fixes:
223
+ - "Check the number and order of arguments passed"
224
+ - "Verify keyword arguments match the method signature"
225
+ - "Inspect the value being passed for the expected type or range"
226
+
227
+ type_error:
228
+ issue: "TypeError"
229
+ fixes:
230
+ - "Ensure the value is the expected type before using it"
231
+ - "Add an explicit conversion (to_s, to_i, to_a) where needed"
232
+ - "Guard against nil or unexpected objects in the operation"
233
+
234
+ zero_division_error:
235
+ issue: "ZeroDivisionError"
236
+ fixes:
237
+ - "Guard against a zero divisor before dividing"
238
+ - "Return a default or nil when the denominator is zero"
239
+ - "Validate the input values feeding the calculation"
240
+
241
+ json_parse_error:
242
+ issue_fallback: "JSON::ParserError"
243
+ fixes:
244
+ - "Verify the payload is valid JSON before parsing"
245
+ - "Rescue JSON::ParserError and handle malformed input"
246
+ - "Check the content type and encoding of the source data"
247
+
248
+ timeout_error:
249
+ issue_fallback: "Timeout::Error"
250
+ fixes:
251
+ - "Increase the timeout for the slow operation if appropriate"
252
+ - "Inspect the upstream service or query that is running long"
253
+ - "Add retries with backoff for transient timeouts"
254
+
255
+ frozen_error:
256
+ issue: "FrozenError"
257
+ fixes:
258
+ - "Avoid mutating a frozen object; work on a duplicate with dup"
259
+ - "Check for frozen string literals when modifying strings"
260
+ - "Build a new object instead of mutating the frozen one"
261
+
262
+ runtime_error:
263
+ issue_fallback: "RuntimeError"
264
+ fixes:
265
+ - "Read the error message for the specific failure"
266
+ - "Inspect the code path that raised the error"
267
+ - "Add handling or validation around the failing operation"
268
+
269
+ generic_exception:
270
+ issue_fallback: "Exception"
271
+ fixes:
272
+ - "Inspect the failing code path and surrounding stack trace"
273
+ - "Verify the expected input, state, or configuration"
274
+ - "Add a targeted rescue or validation around the failing operation"
275
+
276
+ http_errors:
277
+ issue: "HTTP %{status} Response"
278
+ status_label_fallback: "HTTP %{status}"
279
+ message:
280
+ base: "%{status} %{status_label}"
281
+ at_location: " at %{location}"
282
+ with_detail: ": %{detail}"
283
+ fixes:
284
+ "400":
285
+ - "Review the request payload and required parameters"
286
+ - "Check controller validations for this action"
287
+ - "Confirm the client sends the expected JSON or form fields"
288
+ "401":
289
+ - "Verify authentication credentials or tokens"
290
+ - "Check whether the session has expired"
291
+ - "Confirm the login endpoint receives all required fields"
292
+ "403":
293
+ - "Verify the current user has permission for this action"
294
+ - "Check authorization rules in the controller or policy"
295
+ "404":
296
+ - "Verify the requested resource exists"
297
+ - "Check route params and lookup conditions in the controller"
298
+ "422":
299
+ - "Review model validations and error messages"
300
+ - "Check which fields failed validation in the request payload"
301
+ default:
302
+ - "Inspect the controller action that returned this status"
303
+ - "Check application logs for validation or guard clauses"
304
+ - "Review the API response body for more detail"
305
+
306
+ code_patch:
307
+ no_change: "No code change required."
308
+ delete_line: "- remove line %{line}"
309
+ delete_lines: "- remove lines %{start_line}-%{end_line}"
310
+ insert_before: "+ insert before line %{line}:\n%{replacement}"
311
+ replace_line: "replace line %{line} with:\n%{replacement}"
312
+ replace_lines: "replace lines %{start_line}-%{end_line} with:\n%{replacement}"
313
+ invalid_line_range: "Invalid patch line range."
314
+ patch_ends_after_file_end: "Patch ends after file end."
315
+
316
+ errors:
317
+ location_required: "Location is required."
318
+ fix_text_or_patch_required: "Fix text or AI code patch is required."
319
+ could_not_parse_location: "Could not parse the error location."
320
+ source_file_not_found: "Source file not found: %{file_path}"
321
+ line_out_of_range: "Line %{line_number} is out of range."
322
+ ai_code_patch_missing: "AI code patch is missing."
323
+ no_code_change_required: "AI determined no code change is required."
324
+ suggested_code_already_exists: "Suggested code already exists in this file. No changes applied."
325
+ fix_only_in_dev_test: "Fix application is only available in development and test."
326
+ fix_applied: "AI fix applied to %{file_path}:%{line_number}."
327
+ ai_context_not_available: "AI context is not available for this error."
328
+ enter_message: "Enter a message to send."
329
+ default_chat_reply: "I updated the suggested fix."
330
+ session_logs_cleared: "Session logs cleared."
331
+
332
+ ui:
333
+ formatter:
334
+ analysis: "BugSage Analysis"
335
+ issue: "Issue"
336
+ location: "Location"
337
+ root_cause: "Root Cause"
338
+ suggested_fixes: "Suggested Fixes"
339
+ confidence: "Confidence"
340
+ source: "Source"
341
+ ai_notes: "AI Notes"
342
+ confidence_value: "%{confidence}%"
343
+
344
+ error_page:
345
+ title: "BugSage — %{issue}"
346
+ caught: "🐛 BugSage caught: %{issue}"
347
+ location_label: "Location:"
348
+ error_message: "Error Message:"
349
+ suggested_fixes: "Suggested Fixes"
350
+ confidence_level: "Confidence Level"
351
+ confidence_value: "%{confidence}%"
352
+ rails_request_context: "Rails Request Context"
353
+ ai_enhanced_analysis: "AI-enhanced analysis"
354
+ ai_notes: "AI Notes"
355
+ ai_status: "AI Status"
356
+ ai_error: "AI analysis was enabled but could not run: %{error}"
357
+
358
+ dashboard:
359
+ title: "BugSage Dashboard"
360
+ brand: "🐛 BugSage"
361
+ session_errors: "Session errors"
362
+ caught_count: "%{count} caught"
363
+ avg_confidence: "%{confidence}% avg"
364
+ avg_empty: "—"
365
+ empty_list_title: "No issues yet"
366
+ empty_list_hint: "Trigger an exception and it will appear here."
367
+ empty_detail_title: "No errors captured"
368
+ empty_detail_body: "When BugSage catches an exception, select it from the list on the left to inspect the failing code, message, and suggested fixes."
369
+ confidence_badge: "%{confidence}% confidence"
370
+ location_label: "Location:"
371
+ time_label: "Time:"
372
+ error_message: "Error Message:"
373
+ suggested_fixes: "Suggested fixes"
374
+ analysis: "Analysis"
375
+ rails_request_context: "Rails request context"
376
+ ai_enhanced: "AI-enhanced"
377
+ ai_notes: "AI notes"
378
+ ai_status: "AI status"
379
+ ai_error: "AI analysis was enabled but could not run: %{error}"
380
+ ai_analysis_failed: "AI analysis was enabled but failed: %{error}"
381
+ analysis_hybrid: "%{confidence}% confidence after combining BugSage rules with AI analysis."
382
+ analysis_rules: "%{confidence}% match for this exception type based on BugSage rules."
383
+
384
+ page_actions:
385
+ clear_session_logs: "Clear session logs"
386
+ open_in_cursor: "Open in Cursor"
387
+ open_in_vscode: "Open in VS Code"
388
+ copy_fix: "Copy Fix"
389
+ apply_fix_to_file: "Apply Fix to File"
390
+ confirm_clear: "Clear all BugSage session logs?"
391
+ could_not_clear: "Could not clear session logs."
392
+ quick_fix_title: "BugSage quick fix"
393
+ quick_fix_location: "Location: %{location}"
394
+ quick_fix_suggestion: "Suggestion: %{fix}"
395
+ copied: "Copied!"
396
+ confirm_apply: "Apply this fix to the source file in development?"
397
+ applying: "Applying..."
398
+ could_not_apply: "Could not apply fix."
399
+ applied: "Applied!"
400
+
401
+ ai_panel:
402
+ ai_suggestions: "AI Suggestions"
403
+ chat_about_error_title: "Chat about this error"
404
+ chat_about_error_aria: "Chat about this error"
405
+ enable_ai: "Enable AI"
406
+ help: "Rule-based analysis loads instantly. Click below when you want AI-enhanced fixes."
407
+ quick_fix_suggestion: "Quick Fix Suggestion"
408
+ apply_ai_to_codebase: "Apply AI to Codebase"
409
+ open_in: "Open in"
410
+ cursor: "Cursor"
411
+ vscode: "VS Code"
412
+ loading_steps:
413
+ - "Reading source code..."
414
+ - "Analyzing exception..."
415
+ - "Generating fix suggestion..."
416
+ - "Almost there..."
417
+ thinking: "Thinking..."
418
+ chat_request_failed: "Chat request failed."
419
+ code_patch_updated: "Code patch updated from chat. Review the preview before applying."
420
+ confirm_apply_ai: "Apply this AI suggestion directly to your codebase?"
421
+ applying: "Applying..."
422
+ could_not_apply_ai: "Could not apply AI fix."
423
+ applied: "Applied!"
424
+ requesting_ai: "Requesting AI suggestion..."
425
+ ai_enhanced_applied: "AI-enhanced suggestion applied."
426
+ suggestion_updated: "Suggestion updated."
427
+ confidence_suffix: " confidence"
428
+ assistant_welcome: "AI suggestion is ready. Ask me to explain the fix, suggest alternatives, or refine the code change."
429
+
430
+ ai_chat:
431
+ working_title: "BugSage AI is working"
432
+ reading_source: "Reading source code..."
433
+ chat_header: "💬 Chat about this error"
434
+ close_chat_aria: "Close chat"
435
+ input_placeholder: "Ask about the fix or request code changes..."
436
+ send: "Send"
437
+
438
+ exceptions_app:
439
+ unclassified: "BugSage could not classify this exception."
@@ -0,0 +1,152 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bugsage
4
+ class Middleware
5
+ CASCADE_HEADER = "X-Cascade"
6
+
7
+ def initialize(app)
8
+ @app = app
9
+ end
10
+
11
+ def call(env)
12
+ return handle_dashboard(env) if dashboard_request?(env)
13
+ return InlineConsole.handle_request(env) if console_request?(env)
14
+ return AiPanel.handle_request(env) if ai_suggest_request?(env)
15
+ return AiChat.handle_request(env) if ai_chat_request?(env)
16
+ return FixApplicator.handle_request(env) if apply_fix_request?(env)
17
+ return SessionClear.handle_request(env) if clear_request?(env)
18
+ return @app.call(env) unless Bugsage.configuration.enabled?
19
+
20
+ status, headers, body = @app.call(env)
21
+ body_parts = extract_body(body)
22
+ close_body(body)
23
+
24
+ return pass_through(status, headers, body_parts) if bugsage_response?(body_parts)
25
+
26
+ rendered = capture_routing_error(env, headers)
27
+ return rendered if rendered
28
+
29
+ rendered = capture_exception(env)
30
+ return rendered if rendered
31
+
32
+ capture_http_error(env, status, body_parts.join)
33
+ pass_through(status, headers, body_parts)
34
+ rescue StandardError => e
35
+ result = capture_exception(env, e)
36
+ return result if result.is_a?(Array)
37
+
38
+ raise e
39
+ end
40
+
41
+ private
42
+
43
+ def handle_dashboard(env)
44
+ if Bugsage.configuration.show_dashboard?
45
+ render_dashboard
46
+ else
47
+ @app.call(env)
48
+ end
49
+ end
50
+
51
+ def dashboard_request?(env)
52
+ path = env["PATH_INFO"].to_s
53
+ ["/bugsage", "/bugsage/"].include?(path)
54
+ end
55
+
56
+ def console_request?(env)
57
+ env["REQUEST_METHOD"] == "POST" && env["PATH_INFO"] == "/bugsage/console"
58
+ end
59
+
60
+ def ai_suggest_request?(env)
61
+ env["REQUEST_METHOD"] == "POST" && env["PATH_INFO"] == AiPanel::ENDPOINT
62
+ end
63
+
64
+ def ai_chat_request?(env)
65
+ env["REQUEST_METHOD"] == "POST" && env["PATH_INFO"] == AiChat::ENDPOINT
66
+ end
67
+
68
+ def apply_fix_request?(env)
69
+ env["REQUEST_METHOD"] == "POST" && env["PATH_INFO"] == FixApplicator::ENDPOINT
70
+ end
71
+
72
+ def clear_request?(env)
73
+ env["REQUEST_METHOD"] == "POST" && env["PATH_INFO"] == SessionClear::ENDPOINT
74
+ end
75
+
76
+ def capture_routing_error(env, headers)
77
+ return unless cascade_pass?(headers)
78
+
79
+ exception = routing_error_for(env)
80
+ capture_exception(env, exception)
81
+ end
82
+
83
+ def routing_error_for(env)
84
+ if defined?(ActionController::RoutingError)
85
+ ActionController::RoutingError.new(
86
+ "No route matches [#{env["REQUEST_METHOD"]}] #{env["PATH_INFO"].inspect}"
87
+ )
88
+ else
89
+ StandardError.new(
90
+ "No route matches [#{env["REQUEST_METHOD"]}] #{env["PATH_INFO"].inspect}"
91
+ )
92
+ end
93
+ end
94
+
95
+ def cascade_pass?(headers)
96
+ headers[CASCADE_HEADER] == "pass" ||
97
+ (defined?(ActionDispatch::Constants) && headers[ActionDispatch::Constants::X_CASCADE] == "pass")
98
+ end
99
+
100
+ def close_body(body)
101
+ body.close if body.respond_to?(:close)
102
+ end
103
+
104
+ def extract_body(body)
105
+ return Array(body) unless body.respond_to?(:each)
106
+
107
+ # Prefer each over map: ActionDispatch::Response::RackBody implements each only.
108
+ parts = []
109
+ body.each { |part| parts << part }
110
+ parts
111
+ end
112
+
113
+ def capture_http_error(env, status, body)
114
+ config = Bugsage.configuration
115
+ return unless config.capture_http_errors?
116
+ return unless HttpErrorCapture.capture?(status, env)
117
+
118
+ ExceptionHandler.store_http_error(env, status, body)
119
+ end
120
+
121
+ def capture_exception(env, exception = nil)
122
+ config = Bugsage.configuration
123
+ return unless config.enabled?
124
+
125
+ candidate = exception || ExceptionSupport.extract(env)
126
+ return unless candidate
127
+
128
+ return ExceptionHandler.render_response(env, candidate) if config.show_error_page?
129
+
130
+ return unless config.capture_errors?
131
+
132
+ ExceptionHandler.store_exception(env, candidate)
133
+ :stored
134
+ end
135
+
136
+ def bugsage_response?(body)
137
+ ExceptionHandler.bugsage_response?(body)
138
+ end
139
+
140
+ def pass_through(status, headers, body)
141
+ [status, headers, body]
142
+ end
143
+
144
+ def render_dashboard
145
+ [200, { "Content-Type" => "text/html" }, [Dashboard.render(Store.all)]]
146
+ end
147
+
148
+ def rails_context(env)
149
+ RequestContext.from_env(env)
150
+ end
151
+ end
152
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "uri"
6
+
7
+ module Bugsage
8
+ class OpenAiClient
9
+ def initialize(config: Bugsage.configuration)
10
+ @config = config
11
+ end
12
+
13
+ def complete(system_prompt:, user_prompt:)
14
+ post_chat(
15
+ temperature: 0.2,
16
+ response_format: { type: "json_object" },
17
+ messages: [
18
+ { role: "system", content: system_prompt },
19
+ { role: "user", content: user_prompt }
20
+ ]
21
+ )
22
+ end
23
+
24
+ def chat(system_prompt:, messages:)
25
+ post_chat(
26
+ temperature: 0.3,
27
+ messages: [{ role: "system", content: system_prompt }] + messages
28
+ )
29
+ end
30
+
31
+ def http_request(uri, request)
32
+ Net::HTTP.start(
33
+ uri.host,
34
+ uri.port,
35
+ use_ssl: uri.scheme == "https",
36
+ open_timeout: @config.ai_timeout,
37
+ read_timeout: @config.ai_timeout
38
+ ) do |http|
39
+ http.request(request)
40
+ end
41
+ end
42
+
43
+ private
44
+
45
+ def post_chat(messages:, temperature:, response_format: nil)
46
+ validate_api_key!
47
+
48
+ uri = URI.join(api_base, "chat/completions")
49
+ request = Net::HTTP::Post.new(uri)
50
+ request["Authorization"] = "Bearer #{@config.resolved_openai_api_key}"
51
+ request["Content-Type"] = "application/json"
52
+
53
+ body = {
54
+ model: @config.openai_model,
55
+ temperature: temperature,
56
+ messages: messages
57
+ }
58
+ body[:response_format] = response_format if response_format
59
+ request.body = JSON.generate(body)
60
+
61
+ response = http_request(uri, request)
62
+ raise Error, error_message_for(response) unless response.is_a?(Net::HTTPSuccess)
63
+
64
+ payload = JSON.parse(response.body)
65
+ content = payload.dig("choices", 0, "message", "content")
66
+ raise Error, "OpenAI response did not include message content" if content.to_s.strip.empty?
67
+
68
+ content
69
+ end
70
+
71
+ def api_base
72
+ base = @config.openai_api_base.to_s
73
+ base.end_with?("/") ? base : "#{base}/"
74
+ end
75
+
76
+ def validate_api_key!
77
+ key = @config.resolved_openai_api_key.to_s
78
+ return if key.strip.empty?
79
+
80
+ return unless key.start_with?("crsr_")
81
+
82
+ raise Error,
83
+ "A Cursor API key was provided, but the OpenAI provider is selected. " \
84
+ "Set config.bugsage.ai_provider = :cursor or use CURSOR_API_KEY."
85
+ end
86
+
87
+ def error_message_for(response)
88
+ body = JSON.parse(response.body)
89
+ detail = body.dig("error", "message") || body["message"]
90
+ hint = case response.code.to_i
91
+ when 401 then "Check that OPENAI_API_KEY is valid."
92
+ when 429 then "Rate limit or billing quota exceeded. Check usage at platform.openai.com."
93
+ end
94
+
95
+ message = "OpenAI request failed with status #{response.code}"
96
+ message += ": #{detail}" if detail
97
+ message += " #{hint}" if hint
98
+ message
99
+ rescue JSON::ParserError
100
+ "OpenAI request failed with status #{response.code}"
101
+ end
102
+ end
103
+ end