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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +14 -0
- data/MIT-LICENSE +20 -0
- data/README.md +281 -0
- data/app/assets/tailwind/bug_reports_client/engine.css +7 -0
- data/app/controllers/bug_reports_client/application_controller.rb +35 -0
- data/app/controllers/bug_reports_client/bug_reports_controller.rb +308 -0
- data/app/controllers/bug_reports_client/webhooks_controller.rb +94 -0
- data/app/helpers/bug_reports_client/bug_reports_helper.rb +61 -0
- data/app/javascript/bug_reports_client/controllers/error_summary_controller.js +14 -0
- data/app/javascript/bug_reports_client/controllers/file_limit_controller.js +31 -0
- data/app/javascript/bug_reports_client/controllers/report_type_controller.js +54 -0
- data/app/javascript/bug_reports_client/controllers/screenshot_dropzone_controller.js +151 -0
- data/app/jobs/bug_reports_client/report_error_job.rb +44 -0
- data/app/models/bug_reports_client/application_record.rb +5 -0
- data/app/models/bug_reports_client/bug_report.rb +74 -0
- data/app/models/bug_reports_client/error_event.rb +46 -0
- data/app/models/concerns/bug_reports_client/reporter.rb +15 -0
- data/app/services/bug_reports_client/api_client.rb +174 -0
- data/app/services/bug_reports_client/description_builder.rb +91 -0
- data/app/views/bug_reports_client/bug_reports/_field_checkbox.html.erb +18 -0
- data/app/views/bug_reports_client/bug_reports/_field_select.html.erb +15 -0
- data/app/views/bug_reports_client/bug_reports/_field_text.html.erb +13 -0
- data/app/views/bug_reports_client/bug_reports/_field_textarea.html.erb +13 -0
- data/app/views/bug_reports_client/bug_reports/_filters.html.erb +31 -0
- data/app/views/bug_reports_client/bug_reports/_form.html.erb +185 -0
- data/app/views/bug_reports_client/bug_reports/_pagination.html.erb +21 -0
- data/app/views/bug_reports_client/bug_reports/_rating_badge.html.erb +11 -0
- data/app/views/bug_reports_client/bug_reports/_related_error.html.erb +20 -0
- data/app/views/bug_reports_client/bug_reports/_status_badge.html.erb +12 -0
- data/app/views/bug_reports_client/bug_reports/_type_badge.html.erb +6 -0
- data/app/views/bug_reports_client/bug_reports/all.html.erb +66 -0
- data/app/views/bug_reports_client/bug_reports/edit.html.erb +40 -0
- data/app/views/bug_reports_client/bug_reports/index.html.erb +104 -0
- data/app/views/bug_reports_client/bug_reports/new.html.erb +33 -0
- data/app/views/bug_reports_client/shared/_after_dismiss.turbo_stream.erb +4 -0
- data/app/views/bug_reports_client/shared/_alerts.html.erb +22 -0
- data/config/form_schema.yml +114 -0
- data/config/importmap.rb +6 -0
- data/config/locales/en.yml +213 -0
- data/config/routes.rb +14 -0
- data/db/migrate/20260721000001_create_bug_reports.rb +32 -0
- data/db/migrate/20260723000002_create_bug_report_error_events.rb +20 -0
- data/db/migrate/20260723000003_add_activity_to_bug_report_error_events.rb +10 -0
- data/lib/bug_reports_client/configuration.rb +121 -0
- data/lib/bug_reports_client/engine.rb +49 -0
- data/lib/bug_reports_client/error_context.rb +31 -0
- data/lib/bug_reports_client/error_reporter.rb +116 -0
- data/lib/bug_reports_client/form_schema.rb +204 -0
- data/lib/bug_reports_client/main_app_routes.rb +68 -0
- data/lib/bug_reports_client/version.rb +3 -0
- data/lib/bug_reports_client.rb +39 -0
- data/lib/generators/bug_reports_client/install/install_generator.rb +39 -0
- data/lib/generators/bug_reports_client/install/templates/AFTER_INSTALL +28 -0
- data/lib/generators/bug_reports_client/install/templates/bug_report_issue.md.example +23 -0
- data/lib/generators/bug_reports_client/install/templates/initializer.rb +31 -0
- data/lib/generators/bug_reports_client/views/views_generator.rb +19 -0
- 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,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
|