bug_reports_client 0.1.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 (58) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +14 -0
  3. data/MIT-LICENSE +20 -0
  4. data/README.md +281 -0
  5. data/app/assets/tailwind/bug_reports_client/engine.css +7 -0
  6. data/app/controllers/bug_reports_client/application_controller.rb +35 -0
  7. data/app/controllers/bug_reports_client/bug_reports_controller.rb +308 -0
  8. data/app/controllers/bug_reports_client/webhooks_controller.rb +94 -0
  9. data/app/helpers/bug_reports_client/bug_reports_helper.rb +61 -0
  10. data/app/javascript/bug_reports_client/controllers/error_summary_controller.js +14 -0
  11. data/app/javascript/bug_reports_client/controllers/file_limit_controller.js +31 -0
  12. data/app/javascript/bug_reports_client/controllers/report_type_controller.js +54 -0
  13. data/app/javascript/bug_reports_client/controllers/screenshot_dropzone_controller.js +151 -0
  14. data/app/jobs/bug_reports_client/report_error_job.rb +44 -0
  15. data/app/models/bug_reports_client/application_record.rb +5 -0
  16. data/app/models/bug_reports_client/bug_report.rb +74 -0
  17. data/app/models/bug_reports_client/error_event.rb +46 -0
  18. data/app/models/concerns/bug_reports_client/reporter.rb +15 -0
  19. data/app/services/bug_reports_client/api_client.rb +174 -0
  20. data/app/services/bug_reports_client/description_builder.rb +91 -0
  21. data/app/views/bug_reports_client/bug_reports/_field_checkbox.html.erb +18 -0
  22. data/app/views/bug_reports_client/bug_reports/_field_select.html.erb +15 -0
  23. data/app/views/bug_reports_client/bug_reports/_field_text.html.erb +13 -0
  24. data/app/views/bug_reports_client/bug_reports/_field_textarea.html.erb +13 -0
  25. data/app/views/bug_reports_client/bug_reports/_filters.html.erb +31 -0
  26. data/app/views/bug_reports_client/bug_reports/_form.html.erb +185 -0
  27. data/app/views/bug_reports_client/bug_reports/_pagination.html.erb +21 -0
  28. data/app/views/bug_reports_client/bug_reports/_rating_badge.html.erb +11 -0
  29. data/app/views/bug_reports_client/bug_reports/_related_error.html.erb +20 -0
  30. data/app/views/bug_reports_client/bug_reports/_status_badge.html.erb +12 -0
  31. data/app/views/bug_reports_client/bug_reports/_type_badge.html.erb +6 -0
  32. data/app/views/bug_reports_client/bug_reports/all.html.erb +66 -0
  33. data/app/views/bug_reports_client/bug_reports/edit.html.erb +40 -0
  34. data/app/views/bug_reports_client/bug_reports/index.html.erb +104 -0
  35. data/app/views/bug_reports_client/bug_reports/new.html.erb +33 -0
  36. data/app/views/bug_reports_client/shared/_after_dismiss.turbo_stream.erb +4 -0
  37. data/app/views/bug_reports_client/shared/_alerts.html.erb +22 -0
  38. data/config/form_schema.yml +114 -0
  39. data/config/importmap.rb +6 -0
  40. data/config/locales/en.yml +213 -0
  41. data/config/routes.rb +14 -0
  42. data/db/migrate/20260721000001_create_bug_reports.rb +32 -0
  43. data/db/migrate/20260723000002_create_bug_report_error_events.rb +20 -0
  44. data/db/migrate/20260723000003_add_activity_to_bug_report_error_events.rb +10 -0
  45. data/lib/bug_reports_client/configuration.rb +121 -0
  46. data/lib/bug_reports_client/engine.rb +49 -0
  47. data/lib/bug_reports_client/error_context.rb +31 -0
  48. data/lib/bug_reports_client/error_reporter.rb +116 -0
  49. data/lib/bug_reports_client/form_schema.rb +204 -0
  50. data/lib/bug_reports_client/main_app_routes.rb +68 -0
  51. data/lib/bug_reports_client/version.rb +3 -0
  52. data/lib/bug_reports_client.rb +39 -0
  53. data/lib/generators/bug_reports_client/install/install_generator.rb +39 -0
  54. data/lib/generators/bug_reports_client/install/templates/AFTER_INSTALL +28 -0
  55. data/lib/generators/bug_reports_client/install/templates/bug_report_issue.md.example +23 -0
  56. data/lib/generators/bug_reports_client/install/templates/initializer.rb +31 -0
  57. data/lib/generators/bug_reports_client/views/views_generator.rb +19 -0
  58. metadata +134 -0
@@ -0,0 +1,20 @@
1
+ # Per-user log of captured 500s. When a signed-in user hits an error, an
2
+ # event is recorded against them so the bug report form can ask "did your
3
+ # problem relate to this error?" and thread the details into the report.
4
+ class CreateBugReportErrorEvents < ActiveRecord::Migration[8.0]
5
+ def change
6
+ return if table_exists?(:bug_report_error_events)
7
+
8
+ create_table :bug_report_error_events do |t|
9
+ t.bigint :user_id, null: false
10
+ t.string :fingerprint, null: false
11
+ t.string :exception_class, null: false
12
+ t.string :message
13
+ t.datetime :occurred_at, null: false
14
+
15
+ t.timestamps
16
+ end
17
+
18
+ add_index :bug_report_error_events, [ :user_id, :occurred_at ]
19
+ end
20
+ end
@@ -0,0 +1,10 @@
1
+ # Human-readable description of what the user was doing when the error was
2
+ # captured ("viewing invoices") - shown on the report form instead of the
3
+ # technical exception details.
4
+ class AddActivityToBugReportErrorEvents < ActiveRecord::Migration[8.0]
5
+ def change
6
+ return if column_exists?(:bug_report_error_events, :activity)
7
+
8
+ add_column :bug_report_error_events, :activity, :string
9
+ end
10
+ end
@@ -0,0 +1,121 @@
1
+ module BugReportsClient
2
+ # Holds all host-app configuration for the engine. Every setting has a
3
+ # sensible default (mostly environment-variable backed) so a typical host
4
+ # only needs to set `source` in its initializer:
5
+ #
6
+ # BugReportsClient.configure do |config|
7
+ # config.source = "myapp"
8
+ # end
9
+ #
10
+ # Wording is intentionally NOT configured here - all user-facing copy lives
11
+ # in i18n (see config/locales/en.yml) and form fields are defined in the
12
+ # form schema YAML, so this class only carries behavioural settings.
13
+ class Configuration
14
+ # Connection to the bug-reports API.
15
+ attr_accessor :api_url, :api_key, :webhook_secret
16
+
17
+ # Identity of this app. `source` must match the ApiKey name registered on
18
+ # the API and a key in its repo mapping. `app_host` is the public HTTPS
19
+ # origin of this app, used to build the callback URL and screenshot links.
20
+ attr_accessor :source, :app_host, :app_name
21
+
22
+ # Where the engine is mounted, used to derive the default callback URL.
23
+ attr_accessor :mount_path
24
+ attr_writer :callback_url
25
+
26
+ # Integration with the host's controllers and user model.
27
+ # `authenticate_method` is the host method the engine calls to enforce
28
+ # login. Set it to nil when the parent_controller already authenticates
29
+ # globally (e.g. its own before_action :authenticate_user!, which the
30
+ # engine inherits) so authentication does not run twice per request.
31
+ attr_accessor :parent_controller, :current_user_method, :authenticate_method,
32
+ :user_class, :reporter_email_method, :reporter_name_method
33
+
34
+ # Behavioural flags. `reporter_external` and `admin_check` are procs
35
+ # receiving the current user; `ask_severity` hides the severity picker
36
+ # (consumer-facing apps) and submits `default_severity` instead.
37
+ attr_accessor :reporter_external, :admin_check, :ask_severity,
38
+ :default_severity, :screenshots_enabled, :max_screenshots,
39
+ :screenshot_content_types, :max_screenshot_size
40
+
41
+ # Automatic error capture (Sentry-style 500 reporting). Opt-in per host;
42
+ # `error_ignore` lists extra exception class names to skip (classes Rails
43
+ # maps to non-5xx responses are skipped automatically), and the throttle
44
+ # limits how often one fingerprint is posted from this app.
45
+ attr_accessor :error_reporting_enabled, :error_ignore, :error_throttle_period
46
+
47
+ # Optional overrides for the form definition and GitHub issue template.
48
+ # When nil, the engine looks for config/bug_report_form.yml and
49
+ # config/bug_report_issue.md in the host app, then falls back to its own
50
+ # defaults.
51
+ attr_accessor :form_schema_path, :issue_template_path
52
+
53
+ def initialize
54
+ @api_url = ENV.fetch("BUG_REPORT_API_URL", "http://localhost:3002/api")
55
+ @api_key = ENV["BUG_REPORT_API_KEY"]
56
+ @webhook_secret = ENV["BUG_REPORT_WEBHOOK_SECRET"]
57
+ @app_host = ENV["APP_HOST"]
58
+ @app_name = nil
59
+ @source = nil
60
+ @mount_path = "/bug_reports"
61
+ @callback_url = nil
62
+ @parent_controller = "::ApplicationController"
63
+ @current_user_method = :current_user
64
+ @authenticate_method = :authenticate_user!
65
+ @user_class = "User"
66
+ @reporter_email_method = :email
67
+ @reporter_name_method = :name
68
+ @reporter_external = ->(_user) { false }
69
+ @admin_check = ->(_user) { false }
70
+ @ask_severity = true
71
+ @default_severity = "medium"
72
+ @screenshots_enabled = true
73
+ @max_screenshots = 5
74
+ @screenshot_content_types = %w[image/png image/jpeg image/gif image/webp]
75
+ @max_screenshot_size = 10 * 1024 * 1024
76
+ @form_schema_path = nil
77
+ @issue_template_path = nil
78
+ @error_reporting_enabled = false
79
+ @error_ignore = []
80
+ @error_throttle_period = 300
81
+ end
82
+
83
+ # The URL the API calls back when a report's GitHub issue is closed.
84
+ # Must be public HTTPS in production - the API refuses private hosts.
85
+ def callback_url
86
+ @callback_url || begin
87
+ raise ConfigurationError, "BugReportsClient: set config.app_host (or APP_HOST) so the callback URL can be built" if app_host.blank?
88
+ File.join(app_host, mount_path, "webhook")
89
+ end
90
+ end
91
+
92
+ def source!
93
+ raise ConfigurationError, "BugReportsClient: config.source is required (must match your API key name)" if source.blank?
94
+ source
95
+ end
96
+
97
+ # Reporter attribute readers accept either a method name (symbol) or a
98
+ # proc, so hosts without e.g. a `name` method can supply a lambda.
99
+ def reporter_email_for(user)
100
+ resolve(reporter_email_method, user)
101
+ end
102
+
103
+ def reporter_name_for(user)
104
+ resolve(reporter_name_method, user)
105
+ end
106
+
107
+ def external_reporter?(user)
108
+ !!reporter_external.call(user)
109
+ end
110
+
111
+ def admin?(user)
112
+ !!admin_check.call(user)
113
+ end
114
+
115
+ private
116
+
117
+ def resolve(method_or_proc, user)
118
+ method_or_proc.respond_to?(:call) ? method_or_proc.call(user) : user.public_send(method_or_proc)
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,49 @@
1
+ module BugReportsClient
2
+ # Wires the engine into the host app: asset paths for Propshaft, importmap
3
+ # pins for the Stimulus controllers, and helper exposure so host layouts can
4
+ # call `bug_report_alerts` directly.
5
+ class Engine < ::Rails::Engine
6
+ isolate_namespace BugReportsClient
7
+
8
+ config.generators do |g|
9
+ g.test_framework :test_unit
10
+ end
11
+
12
+ # Serve the engine's JavaScript through the host's asset pipeline.
13
+ initializer "bug_reports_client.assets" do |app|
14
+ app.config.assets.paths << root.join("app/javascript") if app.config.respond_to?(:assets)
15
+ end
16
+
17
+ # Merge the engine's importmap pins into the host importmap. The pins live
18
+ # under controllers/bug_reports_client/, which both eagerLoadControllersFrom
19
+ # and lazyLoadControllersFrom scan, so the Stimulus controllers register as
20
+ # bug-reports-client--report-type and bug-reports-client--file-limit with
21
+ # no host JavaScript changes.
22
+ initializer "bug_reports_client.importmap", before: "importmap" do |app|
23
+ if app.config.respond_to?(:importmap)
24
+ app.config.importmap.paths << root.join("config/importmap.rb")
25
+ app.config.importmap.cache_sweepers << root.join("app/javascript")
26
+ end
27
+ end
28
+
29
+ # Make engine helpers (bug_report_alerts and the badge helpers) available
30
+ # in host views, so layouts can render the resolved-report alerts with a
31
+ # single line.
32
+ initializer "bug_reports_client.helpers" do
33
+ ActiveSupport.on_load(:action_controller_base) do
34
+ helper BugReportsClient::Engine.helpers
35
+ # Tag requests with the current user so captured 500s can be
36
+ # attributed to whoever hit them (see ErrorContext).
37
+ include BugReportsClient::ErrorContext
38
+ end
39
+ end
40
+
41
+ # Automatic 500 capture: subscribe to the Rails error reporter. The
42
+ # subscription is unconditional (and cheap) - the reporter itself checks
43
+ # config.error_reporting_enabled per report, so hosts can toggle the
44
+ # flag without a restart and tests behave predictably.
45
+ config.after_initialize do
46
+ Rails.error.subscribe(BugReportsClient::ErrorReporter.new)
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,31 @@
1
+ module BugReportsClient
2
+ # Included into ActionController::Base by the engine so every request tags
3
+ # the Rails error-reporting context with the signed-in user's id. When an
4
+ # unhandled 500 is captured, the reporter reads the id back and records an
5
+ # ErrorEvent against that user - powering the "did your problem relate to
6
+ # this error?" prompt on the bug report form. Deliberately silent on any
7
+ # failure: attribution is best-effort and must never affect the request.
8
+ module ErrorContext
9
+ extend ActiveSupport::Concern
10
+
11
+ included do
12
+ before_action :set_bug_reports_error_context
13
+ end
14
+
15
+ private
16
+
17
+ def set_bug_reports_error_context
18
+ context = { bug_reports_controller: controller_name, bug_reports_action: action_name }
19
+
20
+ method = BugReportsClient.config.current_user_method
21
+ if respond_to?(method, true)
22
+ user = send(method)
23
+ context[:bug_reports_user_id] = user.id if user.respond_to?(:id) && user&.id
24
+ end
25
+
26
+ Rails.error.set_context(**context)
27
+ rescue StandardError
28
+ nil
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,116 @@
1
+ require "digest"
2
+
3
+ module BugReportsClient
4
+ # Subscribes to the Rails error reporter and turns unhandled exceptions
5
+ # (real 500s) into error reports on the central API, which files one GitHub
6
+ # issue per distinct error. Sentry-style basics without the Sentry:
7
+ #
8
+ # - Only unhandled errors are captured, and only ones Rails would answer
9
+ # with a 5xx (RecordNotFound and friends map to 4xx and are skipped).
10
+ # - Errors are fingerprinted by exception class + the top application
11
+ # backtrace frame (line numbers stripped, so deploys that shift code
12
+ # around keep the same fingerprint).
13
+ # - A cache-based throttle stops an error storm flooding the API; the
14
+ # API deduplicates authoritatively by fingerprint regardless.
15
+ #
16
+ # Enable per host with config.error_reporting_enabled = true.
17
+ class ErrorReporter
18
+ BACKTRACE_LINES = 20
19
+
20
+ # Called by Rails.error for every reported error. Must never raise -
21
+ # error reporting failing inside a failing request would mask the
22
+ # original problem.
23
+ def report(error, handled:, severity: nil, context: {}, source: nil)
24
+ return if handled
25
+ return unless BugReportsClient.config.error_reporting_enabled
26
+ return if ignored?(error)
27
+
28
+ fingerprint = self.class.fingerprint_for(error)
29
+ record_user_event(error, fingerprint, context)
30
+
31
+ return unless claim!(fingerprint)
32
+
33
+ ReportErrorJob.perform_later(
34
+ "fingerprint" => fingerprint,
35
+ "exception_class" => error.class.name,
36
+ "message" => error.message.to_s.truncate(2_000),
37
+ "backtrace" => cleaned_backtrace(error),
38
+ "occurred_at" => Time.current.iso8601
39
+ )
40
+ rescue StandardError => e
41
+ Rails.logger.error "BugReportsClient: error reporter failed: #{e.class}: #{e.message}"
42
+ nil
43
+ end
44
+
45
+ # Stable identity for "the same error": class + top application frame
46
+ # with the line number stripped, so unrelated code changes moving lines
47
+ # around do not spawn new issues.
48
+ def self.fingerprint_for(error)
49
+ frame = Rails.backtrace_cleaner.clean(error.backtrace || []).first.to_s
50
+ frame = frame.sub(/:\d+:in /, ":in ")
51
+ Digest::SHA256.hexdigest("#{error.class.name}|#{frame}")[0, 16]
52
+ end
53
+
54
+ private
55
+
56
+ # Skips exception classes Rails maps to non-5xx responses (RecordNotFound
57
+ # -> 404 etc), plus any classes the host lists in config.error_ignore.
58
+ def ignored?(error)
59
+ return true if BugReportsClient.config.error_ignore.include?(error.class.name)
60
+
61
+ status = ActionDispatch::ExceptionWrapper.rescue_responses[error.class.name]
62
+ status.present? && Rack::Utils.status_code(status) < 500
63
+ end
64
+
65
+ # One report per fingerprint per throttle period, enforced through the
66
+ # host's cache. A cache without unless_exist support just means more
67
+ # posts - the API deduplicates anyway.
68
+ def claim!(fingerprint)
69
+ Rails.cache.write(
70
+ "bug_reports_client:error:#{fingerprint}",
71
+ true,
72
+ unless_exist: true,
73
+ expires_in: BugReportsClient.config.error_throttle_period
74
+ )
75
+ end
76
+
77
+ def cleaned_backtrace(error)
78
+ Rails.backtrace_cleaner.clean(error.backtrace || []).first(BACKTRACE_LINES)
79
+ end
80
+
81
+ # Attributes the error to the signed-in user who hit it (id set by
82
+ # ErrorContext), so the report form can offer "did it relate to this
83
+ # error?". Recorded on EVERY occurrence - unlike the API post, which is
84
+ # throttled - and best-effort: failures are swallowed.
85
+ def record_user_event(error, fingerprint, context)
86
+ user_id = context[:bug_reports_user_id]
87
+ return if user_id.blank?
88
+
89
+ ErrorEvent.record!(
90
+ user_id: user_id,
91
+ fingerprint: fingerprint,
92
+ exception_class: error.class.name,
93
+ message: error.message,
94
+ activity: activity_from(context),
95
+ occurred_at: Time.current
96
+ )
97
+ rescue StandardError => e
98
+ Rails.logger.debug { "BugReportsClient: error event not recorded (#{e.class}: #{e.message})" }
99
+ nil
100
+ end
101
+
102
+ # Human-readable "what the user was doing" - shown to users on the report
103
+ # form in place of exception details. Built from the controller/action at
104
+ # capture time: "viewing invoices", "saving changes to players".
105
+ def activity_from(context)
106
+ controller = context[:bug_reports_controller]
107
+ return nil if controller.blank?
108
+
109
+ verb = I18n.t(
110
+ "bug_reports_client.activities.#{context[:bug_reports_action]}",
111
+ default: I18n.t("bug_reports_client.activities.other")
112
+ )
113
+ "#{verb} #{controller.to_s.humanize.downcase}"
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,204 @@
1
+ require "yaml"
2
+
3
+ module BugReportsClient
4
+ # Loads and validates the YAML form definition that drives the report form.
5
+ #
6
+ # Resolution order for the schema file:
7
+ # 1. config.form_schema_path (explicit override)
8
+ # 2. <host app>/config/bug_report_form.yml
9
+ # 3. the engine's config/form_schema.yml default
10
+ #
11
+ # Schema shape - top-level keys are report types (bug/feature), each holding
12
+ # an ordered list of fields:
13
+ #
14
+ # bug:
15
+ # - field: steps_to_reproduce
16
+ # type: textarea # text | textarea | select | checkbox
17
+ # required: true
18
+ # section: reproduction # optional - starts a new card in the form
19
+ # rows: 4 # textareas only
20
+ # options: [Chrome, Safari] # selects only
21
+ # label: "How can we see this?" # optional - falls back to i18n
22
+ # placeholder: "..." # optional - falls back to i18n
23
+ # help: "..." # optional - falls back to i18n
24
+ #
25
+ # Answers are stored in BugReport#responses keyed by the field name. A
26
+ # feature field named `priority` doubles as the report's importance rating
27
+ # shown in list views (mirroring severity for bugs).
28
+ class FormSchema
29
+ REPORT_TYPES = %w[bug feature].freeze
30
+ FIELD_TYPES = %w[text textarea select checkbox].freeze
31
+
32
+ # A single form field parsed from the YAML definition.
33
+ Field = Struct.new(:key, :type, :required, :options, :section, :label, :placeholder, :help, :rows, keyword_init: true) do
34
+ def required? = !!required
35
+ def select? = type == "select"
36
+ def checkbox? = type == "checkbox"
37
+
38
+ # Wording resolution: inline schema values win, then i18n under
39
+ # bug_reports_client.fields.<key>.*, then a humanised fallback.
40
+ def label_text
41
+ label || I18n.t("bug_reports_client.fields.#{key}.label", default: key.humanize)
42
+ end
43
+
44
+ def placeholder_text
45
+ placeholder || I18n.t("bug_reports_client.fields.#{key}.placeholder", default: nil)
46
+ end
47
+
48
+ def help_text
49
+ help || I18n.t("bug_reports_client.fields.#{key}.help", default: nil)
50
+ end
51
+
52
+ def prompt_text
53
+ I18n.t("bug_reports_client.fields.#{key}.prompt",
54
+ default: I18n.t("bug_reports_client.form.select_prompt"))
55
+ end
56
+ end
57
+
58
+ class << self
59
+ # The active schema. Reloaded on every call in development so schema
60
+ # edits show up without a restart; memoised elsewhere.
61
+ def current
62
+ if Rails.env.development?
63
+ load_schema
64
+ else
65
+ @current ||= load_schema
66
+ end
67
+ end
68
+
69
+ def reset!
70
+ @current = nil
71
+ end
72
+
73
+ def default_path
74
+ Engine.root.join("config", "form_schema.yml")
75
+ end
76
+
77
+ private
78
+
79
+ def load_schema
80
+ new(resolve_path)
81
+ end
82
+
83
+ def resolve_path
84
+ explicit = BugReportsClient.config.form_schema_path
85
+ return Pathname.new(explicit) if explicit.present?
86
+
87
+ host_default = Rails.root.join("config", "bug_report_form.yml")
88
+ host_default.exist? ? host_default : default_path
89
+ end
90
+ end
91
+
92
+ attr_reader :path
93
+
94
+ def initialize(path)
95
+ @path = path
96
+ @fields_by_type = parse(path)
97
+ end
98
+
99
+ # Report types the schema defines, in file order. The form only shows the
100
+ # bug/feature toggle when more than one type is present.
101
+ def report_types
102
+ @fields_by_type.keys
103
+ end
104
+
105
+ def fields_for(report_type)
106
+ @fields_by_type.fetch(report_type.to_s, [])
107
+ end
108
+
109
+ def required_fields(report_type)
110
+ fields_for(report_type).select(&:required?)
111
+ end
112
+
113
+ # Every field key across all report types - used for strong parameters so
114
+ # only schema-declared answers are ever written to responses.
115
+ def field_keys
116
+ @fields_by_type.values.flatten.map(&:key).uniq
117
+ end
118
+
119
+ # Field keys for one report type, falling back to all keys when the type
120
+ # is unknown/blank. Used to scope strong parameters to the selected type
121
+ # so a bug submission can't smuggle in feature answers (and vice versa).
122
+ def field_keys_for(report_type)
123
+ fields = fields_for(report_type)
124
+ fields.any? ? fields.map(&:key).uniq : field_keys
125
+ end
126
+
127
+ # Fields grouped into ordered sections for rendering. Fields before any
128
+ # `section` marker fall into a nil section rendered without a heading.
129
+ def sections_for(report_type)
130
+ fields_for(report_type).chunk_while { |a, b| a.section == b.section }.map do |group|
131
+ [ group.first.section, group ]
132
+ end
133
+ end
134
+
135
+ private
136
+
137
+ def parse(path)
138
+ raise SchemaError, "Bug report form schema not found at #{path}" unless File.exist?(path)
139
+
140
+ raw = YAML.safe_load_file(path)
141
+ raise SchemaError, "#{path}: schema must be a hash of report types (bug/feature)" unless raw.is_a?(Hash)
142
+
143
+ raw.each_with_object({}) do |(report_type, fields), parsed|
144
+ unless REPORT_TYPES.include?(report_type.to_s)
145
+ raise SchemaError, "#{path}: unknown report type #{report_type.inspect} (expected one of #{REPORT_TYPES.join(', ')})"
146
+ end
147
+ raise SchemaError, "#{path}: #{report_type} must contain a list of fields" unless fields.is_a?(Array)
148
+
149
+ parsed[report_type.to_s] = fields.map { |definition| build_field(report_type, definition) }
150
+ end.tap do |parsed|
151
+ raise SchemaError, "#{path}: schema defines no report types" if parsed.empty?
152
+ end
153
+ end
154
+
155
+ def build_field(report_type, definition)
156
+ raise SchemaError, "#{path}: each #{report_type} field must be a hash with a `field` key" unless definition.is_a?(Hash)
157
+
158
+ key = definition["field"].to_s
159
+ raise SchemaError, "#{path}: #{report_type} entry missing `field` name: #{definition.inspect}" if key.blank?
160
+ unless key.match?(/\A[a-z][a-z0-9_]*\z/)
161
+ raise SchemaError, "#{path}: field name #{key.inspect} must be snake_case (letters, digits, underscores)"
162
+ end
163
+
164
+ type = (definition["type"] || "text").to_s
165
+ unless FIELD_TYPES.include?(type)
166
+ raise SchemaError, "#{path}: field #{key} has unknown type #{type.inspect} (expected one of #{FIELD_TYPES.join(', ')})"
167
+ end
168
+ if type == "select" && !definition["options"].is_a?(Array)
169
+ raise SchemaError, "#{path}: select field #{key} needs an `options` list"
170
+ end
171
+
172
+ Field.new(
173
+ key: key,
174
+ type: type,
175
+ required: definition["required"] || false,
176
+ options: normalise_options(key, definition["options"]),
177
+ section: definition["section"],
178
+ label: definition["label"],
179
+ placeholder: definition["placeholder"],
180
+ help: definition["help"],
181
+ rows: definition["rows"] || 3
182
+ )
183
+ end
184
+
185
+ # Select options may be plain strings (label doubles as the stored value)
186
+ # or {value:, label:} hashes when the stored value should differ from the
187
+ # display text. Normalised to [label, value] pairs for options_for_select.
188
+ def normalise_options(key, options)
189
+ return nil if options.nil?
190
+
191
+ options.map do |option|
192
+ case option
193
+ when String then [ option, option ]
194
+ when Hash
195
+ value = option["value"].to_s
196
+ raise SchemaError, "#{path}: option for #{key} missing `value`: #{option.inspect}" if value.blank?
197
+ [ option["label"] || value.humanize, value ]
198
+ else
199
+ raise SchemaError, "#{path}: option for #{key} must be a string or {value:, label:} hash"
200
+ end
201
+ end
202
+ end
203
+ end
204
+ end
@@ -0,0 +1,68 @@
1
+ module BugReportsClient
2
+ # Included into engine views (via the engine's ApplicationController) so
3
+ # host layouts render unmodified inside engine pages. Isolated engines
4
+ # resolve route helpers against their own routes, which breaks host-only
5
+ # helpers (root_path, profile_path, etc) used in host layouts and navbars.
6
+ # This delegates any unknown *_path / *_url helper to the host app, while
7
+ # the engine's own helpers keep resolving first because they are real
8
+ # methods and never reach method_missing.
9
+ module MainAppRoutes
10
+ def method_missing(method, *args, &block)
11
+ if main_app_route_helper?(method)
12
+ main_app.public_send(method, *args)
13
+ else
14
+ super
15
+ end
16
+ end
17
+
18
+ def respond_to_missing?(method, include_private = false)
19
+ main_app_route_helper?(method) || super
20
+ end
21
+
22
+ # Active Storage URLs (blobs, attachments, variants - e.g. a user avatar
23
+ # in a host navbar, or image_tag on an attachment) resolve through
24
+ # polymorphic mappings that only exist on the HOST's route set, so route
25
+ # them there explicitly. image_tag goes through polymorphic_url, other
26
+ # callers through url_for - cover all three. Everything else keeps the
27
+ # normal lookup.
28
+ def url_for(argument = nil)
29
+ if MainAppRoutes.active_storage_argument?(argument)
30
+ main_app.polymorphic_path(argument)
31
+ else
32
+ super
33
+ end
34
+ end
35
+
36
+ def polymorphic_url(record, options = {})
37
+ if MainAppRoutes.active_storage_argument?(record)
38
+ main_app.polymorphic_url(record, options)
39
+ else
40
+ super
41
+ end
42
+ end
43
+
44
+ def polymorphic_path(record, options = {})
45
+ if MainAppRoutes.active_storage_argument?(record)
46
+ main_app.polymorphic_path(record, options)
47
+ else
48
+ super
49
+ end
50
+ end
51
+
52
+ def self.active_storage_argument?(argument)
53
+ return false unless defined?(ActiveStorage)
54
+
55
+ argument.is_a?(ActiveStorage::Blob) ||
56
+ argument.is_a?(ActiveStorage::Attachment) ||
57
+ argument.is_a?(ActiveStorage::Variant) ||
58
+ argument.is_a?(ActiveStorage::VariantWithRecord) ||
59
+ argument.is_a?(ActiveStorage::Preview)
60
+ end
61
+
62
+ private
63
+
64
+ def main_app_route_helper?(method)
65
+ method.to_s.end_with?("_path", "_url") && main_app.respond_to?(method)
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,3 @@
1
+ module BugReportsClient
2
+ VERSION = "0.1.0"
3
+ end
@@ -0,0 +1,39 @@
1
+ # Entry point for the bug_reports_client gem. Hosts configure the engine in an
2
+ # initializer via BugReportsClient.configure and mount BugReportsClient::Engine
3
+ # in their routes. See README.md for the full setup guide.
4
+ require "bug_reports_client/version"
5
+ require "bug_reports_client/configuration"
6
+ require "bug_reports_client/form_schema"
7
+ require "bug_reports_client/main_app_routes"
8
+ require "bug_reports_client/error_reporter"
9
+ require "bug_reports_client/error_context"
10
+ require "bug_reports_client/engine"
11
+
12
+ module BugReportsClient
13
+ # Raised when the gem is misconfigured (missing source, API key, etc).
14
+ class ConfigurationError < StandardError; end
15
+
16
+ # Raised when a host's form schema file is invalid.
17
+ class SchemaError < StandardError; end
18
+
19
+ class << self
20
+ def config
21
+ @config ||= Configuration.new
22
+ end
23
+
24
+ def configure
25
+ yield(config)
26
+ end
27
+
28
+ # Replaces the configuration entirely. Used by tests to reset state.
29
+ def reset_config!
30
+ @config = Configuration.new
31
+ FormSchema.reset!
32
+ end
33
+
34
+ # The active form schema (host override or the engine default).
35
+ def form_schema
36
+ FormSchema.current
37
+ end
38
+ end
39
+ end