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,94 @@
1
+ module BugReportsClient
2
+ # Receives closure notifications from the bug-reports API when a report's
3
+ # GitHub issue is closed. Deliberately inherits from ActionController::Base
4
+ # (not the host's ApplicationController) so no host auth chain or callbacks
5
+ # interfere with machine-to-machine requests. Payloads are verified with a
6
+ # per-app HMAC-SHA256 signature before anything is touched; when the sender
7
+ # includes a signed timestamp, stale (replayed) callbacks are rejected too.
8
+ class WebhooksController < ActionController::Base
9
+ class SignatureError < StandardError; end
10
+
11
+ # Callback payloads are small JSON documents - anything bigger is bogus.
12
+ MAX_BODY_BYTES = 1_048_576
13
+
14
+ # How old a timestamped callback may be before it is treated as a replay.
15
+ TIMESTAMP_TOLERANCE = 5 * 60
16
+
17
+ skip_forgery_protection
18
+
19
+ # POST /webhook
20
+ def receive
21
+ return head :content_too_large if request.content_length.to_i > MAX_BODY_BYTES
22
+
23
+ raw_body = request.raw_post
24
+ verify_signature!(raw_body)
25
+
26
+ payload = JSON.parse(raw_body)
27
+ Rails.logger.info "BugReportsClient: report closed: ##{payload['bug_report_id']} - #{payload['title']}"
28
+
29
+ # Mark the local record closed so the reporter sees the resolved alert.
30
+ # A bare column write, not update!: legacy reports (or ones predating a
31
+ # form-schema change that added required fields) must still be closable.
32
+ local_report = BugReport.find_by(remote_bug_report_id: payload["bug_report_id"])
33
+ if local_report
34
+ local_report.update_columns(status: "closed", updated_at: Time.current)
35
+ else
36
+ Rails.logger.warn "BugReportsClient: no local report for remote ID #{payload['bug_report_id']}"
37
+ end
38
+
39
+ head :ok
40
+ rescue SignatureError
41
+ head :unauthorized
42
+ rescue JSON::ParserError => e
43
+ Rails.logger.error "BugReportsClient: webhook parse error: #{e.message}"
44
+ head :unprocessable_entity
45
+ end
46
+
47
+ private
48
+
49
+ # Verifies the HMAC-SHA256 signature using the per-app webhook secret.
50
+ # Preferred scheme: X-Timestamp + X-Signature-Timestamped, where the HMAC
51
+ # covers "<timestamp>.<body>" and the timestamp must be recent (replay
52
+ # protection). Falls back to the legacy body-only X-Signature when no
53
+ # timestamp headers are sent.
54
+ def verify_signature!(raw_body)
55
+ secret = BugReportsClient.config.webhook_secret
56
+ if secret.blank?
57
+ Rails.logger.error "BugReportsClient: webhook secret not configured"
58
+ raise SignatureError, "Webhook secret not configured"
59
+ end
60
+
61
+ if request.headers["X-Timestamp"].present?
62
+ verify_timestamped!(secret, raw_body)
63
+ else
64
+ verify_legacy!(secret, raw_body)
65
+ end
66
+ end
67
+
68
+ def verify_timestamped!(secret, raw_body)
69
+ timestamp = request.headers["X-Timestamp"].to_s
70
+ unless timestamp.match?(/\A\d+\z/) && (Time.current.to_i - timestamp.to_i).abs <= TIMESTAMP_TOLERANCE
71
+ Rails.logger.warn "BugReportsClient: webhook timestamp stale or malformed"
72
+ raise SignatureError, "Stale timestamp"
73
+ end
74
+
75
+ signature = request.headers["X-Signature-Timestamped"].to_s
76
+ expected = "sha256=#{OpenSSL::HMAC.hexdigest('SHA256', secret, "#{timestamp}.#{raw_body}")}"
77
+
78
+ unless ActiveSupport::SecurityUtils.secure_compare(signature, expected)
79
+ Rails.logger.warn "BugReportsClient: webhook timestamped signature mismatch"
80
+ raise SignatureError, "Invalid signature"
81
+ end
82
+ end
83
+
84
+ def verify_legacy!(secret, raw_body)
85
+ signature = request.headers["X-Signature"].to_s
86
+ expected = "sha256=#{OpenSSL::HMAC.hexdigest('SHA256', secret, raw_body)}"
87
+
88
+ unless ActiveSupport::SecurityUtils.secure_compare(signature, expected)
89
+ Rails.logger.warn "BugReportsClient: webhook signature mismatch"
90
+ raise SignatureError, "Invalid signature"
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,61 @@
1
+ module BugReportsClient
2
+ # View helpers for the engine's own pages, plus `bug_report_alerts` which is
3
+ # exposed to host layouts so resolved-report notifications can be rendered
4
+ # with a single line: <%= bug_report_alerts %>
5
+ module BugReportsHelper
6
+ # Renders dismissable "your report has been resolved" alerts for the
7
+ # signed-in user. Returns nil quickly when signed out or nothing resolved,
8
+ # so it is safe on every page including public ones.
9
+ def bug_report_alerts
10
+ user = alerts_user
11
+ return if user.nil?
12
+
13
+ resolved = BugReportsClient::BugReport.where(user: user).resolved_and_undismissed.order(created_at: :desc)
14
+ return if resolved.empty?
15
+
16
+ render "bug_reports_client/shared/alerts", resolved_bug_reports: resolved
17
+ end
18
+
19
+ # Pill used for the type filter (All types / Bugs / Features). When
20
+ # active, each type carries its own accent colour to match the row badges.
21
+ def report_type_pill_class(active, type = nil)
22
+ base = "inline-flex items-center gap-1 px-3 py-1 rounded-full border text-sm font-medium transition-colors"
23
+ return "#{base} bg-white text-slate-500 border-slate-200 hover:text-slate-700 hover:border-slate-300" unless active
24
+
25
+ case type
26
+ when "bug" then "#{base} bg-rose-100 text-rose-700 border-rose-200"
27
+ when "feature" then "#{base} bg-indigo-100 text-indigo-700 border-indigo-200"
28
+ else "#{base} bg-slate-700 text-white border-slate-700"
29
+ end
30
+ end
31
+
32
+ # Shared input styling for the default form - one place to tweak the look
33
+ # of every generated field.
34
+ def brc_input_classes
35
+ "w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 " \
36
+ "placeholder:text-slate-400 focus:border-slate-500 focus:outline-none focus:ring-1 focus:ring-slate-500 " \
37
+ "disabled:bg-slate-100 disabled:text-slate-500"
38
+ end
39
+
40
+ def brc_label_classes
41
+ "block text-xs font-medium text-slate-500 uppercase tracking-wide mb-1"
42
+ end
43
+
44
+ private
45
+
46
+ # The current user as seen from the HOST's context (this helper runs in
47
+ # host layouts). Alerts are cosmetic, so ANY failure resolving the user -
48
+ # a missing method, or e.g. Devise raising outside a real request
49
+ # (mailer previews, ActionController.renderer) - means "no alerts", never
50
+ # a broken page.
51
+ def alerts_user
52
+ method = BugReportsClient.config.current_user_method
53
+ return nil unless controller.respond_to?(method, true)
54
+
55
+ controller.send(method)
56
+ rescue StandardError => e
57
+ Rails.logger.debug { "BugReportsClient: bug_report_alerts skipped (#{e.class}: #{e.message})" }
58
+ nil
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,14 @@
1
+ // Scrolls the validation error box into view when a re-rendered form
2
+ // appears. Turbo preserves scroll position on 422 re-renders, so without
3
+ // this a user who submitted from the bottom of a long form never sees the
4
+ // errors at the top.
5
+ //
6
+ // Registered by the host's Stimulus loader as
7
+ // "bug-reports-client--error-summary".
8
+ import { Controller } from "@hotwired/stimulus"
9
+
10
+ export default class extends Controller {
11
+ connect() {
12
+ this.element.scrollIntoView({ behavior: "smooth", block: "start" })
13
+ }
14
+ }
@@ -0,0 +1,31 @@
1
+ // Validates the number of files selected in a file input. Shows a
2
+ // native-style validation message if the limit is exceeded.
3
+ //
4
+ // Registered by the host's Stimulus loader as "bug-reports-client--file-limit".
5
+ //
6
+ // Usage:
7
+ // <div data-controller="bug-reports-client--file-limit"
8
+ // data-bug-reports-client--file-limit-max-value="5">
9
+ // <input type="file" multiple
10
+ // data-bug-reports-client--file-limit-target="input"
11
+ // data-action="change->bug-reports-client--file-limit#validate">
12
+ // </div>
13
+ import { Controller } from "@hotwired/stimulus"
14
+
15
+ export default class extends Controller {
16
+ static targets = ["input"]
17
+ static values = { max: { type: Number, default: 5 } }
18
+
19
+ validate() {
20
+ const input = this.inputTarget
21
+ const max = this.maxValue
22
+
23
+ if (input.files.length > max) {
24
+ input.setCustomValidity(`You can attach a maximum of ${max} images.`)
25
+ input.reportValidity()
26
+ input.value = ""
27
+ } else {
28
+ input.setCustomValidity("")
29
+ }
30
+ }
31
+ }
@@ -0,0 +1,54 @@
1
+ // Swaps the form fields based on the selected report type: bugs and feature
2
+ // requests collect different information, so only the matching group is shown.
3
+ // The hidden group's inputs are disabled so they neither submit nor block
4
+ // `required` validation. Readonly (closed) reports keep their disabled state.
5
+ //
6
+ // Registered by the host's Stimulus loader as "bug-reports-client--report-type".
7
+ //
8
+ // Usage (radio cards - a select or hidden input also works as the target):
9
+ // <form data-controller="bug-reports-client--report-type"
10
+ // data-bug-reports-client--report-type-readonly-value="false">
11
+ // <input type="radio" value="bug" data-bug-reports-client--report-type-target="type"
12
+ // data-action="change->bug-reports-client--report-type#toggle">
13
+ // <div data-bug-reports-client--report-type-target="bugFields">...</div>
14
+ // <div data-bug-reports-client--report-type-target="featureFields">...</div>
15
+ import { Controller } from "@hotwired/stimulus"
16
+
17
+ export default class extends Controller {
18
+ static targets = ["type", "bugFields", "featureFields", "summary"]
19
+ static values = { readonly: Boolean, bugSummary: String, featureSummary: String }
20
+
21
+ connect() {
22
+ this.toggle()
23
+ }
24
+
25
+ toggle() {
26
+ const isFeature = this.currentType() === "feature"
27
+ if (this.hasFeatureFieldsTarget) this.applyGroup(this.featureFieldsTarget, isFeature)
28
+ if (this.hasBugFieldsTarget) this.applyGroup(this.bugFieldsTarget, !isFeature)
29
+
30
+ if (this.hasSummaryTarget) {
31
+ this.summaryTarget.placeholder = isFeature ? this.featureSummaryValue : this.bugSummaryValue
32
+ }
33
+ }
34
+
35
+ // The type targets are either radio cards (one per type) or a single
36
+ // select/hidden input - support both so hosts can restyle freely.
37
+ currentType() {
38
+ const radios = this.typeTargets.filter((input) => input.type === "radio")
39
+ if (radios.length > 0) {
40
+ const checked = radios.find((radio) => radio.checked)
41
+ return checked ? checked.value : "bug"
42
+ }
43
+ return this.typeTargets[0]?.value
44
+ }
45
+
46
+ applyGroup(group, show) {
47
+ group.classList.toggle("hidden", !show)
48
+ if (this.readonlyValue) return
49
+
50
+ group.querySelectorAll("input, select, textarea").forEach((input) => {
51
+ input.disabled = !show
52
+ })
53
+ }
54
+ }
@@ -0,0 +1,151 @@
1
+ // Drag-and-drop screenshot picker with small client-side previews.
2
+ // The hidden multiple file input remains the source of truth for form
3
+ // submission - this controller keeps input.files in sync with an internal
4
+ // DataTransfer store, so the server sees a perfectly normal file upload.
5
+ //
6
+ // Registered by the host's Stimulus loader as
7
+ // "bug-reports-client--screenshot-dropzone".
8
+ //
9
+ // Targets: zone (the drop area), input (hidden file input),
10
+ // previews (thumbnail container), message (limit warnings)
11
+ // Values: max (file limit), limitMessage (warning text, from i18n)
12
+ import { Controller } from "@hotwired/stimulus"
13
+
14
+ export default class extends Controller {
15
+ static targets = ["zone", "input", "previews", "message"]
16
+ static values = {
17
+ max: { type: Number, default: 5 },
18
+ maxBytes: { type: Number, default: 10_485_760 },
19
+ limitMessage: String,
20
+ sizeMessage: String
21
+ }
22
+
23
+ connect() {
24
+ this.store = new DataTransfer()
25
+ }
26
+
27
+ disconnect() {
28
+ this.revokePreviewUrls()
29
+ }
30
+
31
+ browse() {
32
+ this.inputTarget.click()
33
+ }
34
+
35
+ keydown(event) {
36
+ if (event.key === "Enter" || event.key === " ") {
37
+ event.preventDefault()
38
+ this.browse()
39
+ }
40
+ }
41
+
42
+ // Files chosen via the browse dialog are merged into the current selection
43
+ // rather than replacing it, matching how dropping behaves.
44
+ picked() {
45
+ this.add(this.inputTarget.files)
46
+ }
47
+
48
+ dragover(event) {
49
+ event.preventDefault()
50
+ this.zoneTarget.classList.add("border-slate-500", "bg-slate-100")
51
+ }
52
+
53
+ dragleave() {
54
+ this.zoneTarget.classList.remove("border-slate-500", "bg-slate-100")
55
+ }
56
+
57
+ drop(event) {
58
+ event.preventDefault()
59
+ this.dragleave()
60
+ this.add(event.dataTransfer.files)
61
+ }
62
+
63
+ add(fileList) {
64
+ this.clearMessage()
65
+
66
+ for (const file of Array.from(fileList)) {
67
+ if (!file.type.startsWith("image/")) continue
68
+ if (this.alreadySelected(file)) continue
69
+
70
+ // Oversized files are refused here, at selection time - far better
71
+ // than a server-side rejection after the user has submitted.
72
+ if (file.size > this.maxBytesValue) {
73
+ this.showMessage(this.sizeMessageValue.replace("%{filename}", file.name))
74
+ continue
75
+ }
76
+
77
+ if (this.store.items.length >= this.maxValue) {
78
+ this.showMessage(this.limitMessageValue)
79
+ break
80
+ }
81
+
82
+ this.store.items.add(file)
83
+ }
84
+
85
+ this.sync()
86
+ }
87
+
88
+ remove(event) {
89
+ const index = Number(event.currentTarget.dataset.index)
90
+ const kept = new DataTransfer()
91
+
92
+ Array.from(this.store.files).forEach((file, i) => {
93
+ if (i !== index) kept.items.add(file)
94
+ })
95
+
96
+ this.store = kept
97
+ this.clearMessage()
98
+ this.sync()
99
+ }
100
+
101
+ // Keep input.files as the single submission source, then redraw previews.
102
+ sync() {
103
+ this.inputTarget.files = this.store.files
104
+ this.renderPreviews()
105
+ }
106
+
107
+ renderPreviews() {
108
+ this.revokePreviewUrls()
109
+ this.previewsTarget.innerHTML = ""
110
+
111
+ Array.from(this.store.files).forEach((file, index) => {
112
+ const tile = document.createElement("div")
113
+ tile.className = "relative"
114
+
115
+ const img = document.createElement("img")
116
+ img.src = URL.createObjectURL(file)
117
+ img.alt = file.name
118
+ img.className = "h-16 w-16 object-cover rounded-lg border border-slate-200"
119
+
120
+ const removeButton = document.createElement("button")
121
+ removeButton.type = "button"
122
+ removeButton.dataset.index = index
123
+ removeButton.dataset.action = "bug-reports-client--screenshot-dropzone#remove"
124
+ removeButton.setAttribute("aria-label", `Remove ${file.name}`)
125
+ removeButton.className = "absolute -top-1.5 -right-1.5 flex h-5 w-5 items-center justify-center rounded-full bg-slate-700 text-xs leading-none text-white hover:bg-slate-900"
126
+ removeButton.textContent = "×"
127
+
128
+ tile.appendChild(img)
129
+ tile.appendChild(removeButton)
130
+ this.previewsTarget.appendChild(tile)
131
+ })
132
+ }
133
+
134
+ alreadySelected(file) {
135
+ return Array.from(this.store.files).some((existing) =>
136
+ existing.name === file.name && existing.size === file.size && existing.lastModified === file.lastModified
137
+ )
138
+ }
139
+
140
+ revokePreviewUrls() {
141
+ this.previewsTarget.querySelectorAll("img").forEach((img) => URL.revokeObjectURL(img.src))
142
+ }
143
+
144
+ showMessage(text) {
145
+ if (this.hasMessageTarget) this.messageTarget.textContent = text
146
+ }
147
+
148
+ clearMessage() {
149
+ if (this.hasMessageTarget) this.messageTarget.textContent = ""
150
+ }
151
+ }
@@ -0,0 +1,44 @@
1
+ module BugReportsClient
2
+ # Posts a captured error to the central API in the background, so the
3
+ # failing request is never slowed down (or broken further) by reporting.
4
+ # No retries: the API deduplicates by fingerprint and errors tend to recur,
5
+ # so a lost report costs little - a retry storm during an outage costs more.
6
+ class ReportErrorJob < ActiveJob::Base
7
+ queue_as :default
8
+
9
+ def perform(attributes)
10
+ title = "[Error] #{attributes['exception_class']}: #{attributes['message']}".truncate(150)
11
+
12
+ result = ApiClient.new.create_error_report(
13
+ title: title,
14
+ description: build_description(attributes),
15
+ fingerprint: attributes["fingerprint"],
16
+ occurred_at: attributes["occurred_at"]
17
+ )
18
+
19
+ Rails.logger.warn "BugReportsClient: error report not accepted: #{result.message}" unless result.success?
20
+ end
21
+
22
+ private
23
+
24
+ # Markdown for the GitHub issue: exception, message, fingerprint and a
25
+ # trimmed application backtrace.
26
+ def build_description(attributes)
27
+ <<~MARKDOWN
28
+ ## #{attributes['exception_class']}
29
+
30
+ ```
31
+ #{attributes['message']}
32
+ ```
33
+
34
+ **Fingerprint:** `#{attributes['fingerprint']}` | **First captured:** #{attributes['occurred_at']}
35
+
36
+ ### Application backtrace
37
+
38
+ ```
39
+ #{Array(attributes['backtrace']).join("\n")}
40
+ ```
41
+ MARKDOWN
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,5 @@
1
+ module BugReportsClient
2
+ class ApplicationRecord < ActiveRecord::Base
3
+ self.abstract_class = true
4
+ end
5
+ end
@@ -0,0 +1,74 @@
1
+ module BugReportsClient
2
+ # Local record of a report submitted to the central bug-reports API. Stores
3
+ # the report for user viewing/editing; the schema-driven answers live in the
4
+ # `responses` JSON column keyed by field name. Closure updates arrive via
5
+ # the signed webhook and flip status to closed, which surfaces the
6
+ # resolved-report alert until the user dismisses it.
7
+ class BugReport < ApplicationRecord
8
+ self.table_name = "bug_reports"
9
+
10
+ belongs_to :user, class_name: BugReportsClient.config.user_class
11
+
12
+ has_many_attached :screenshots
13
+
14
+ enum :status, { open: "open", closed: "closed" }
15
+ enum :severity, { low: "low", medium: "medium", high: "high", critical: "critical" }
16
+ enum :report_type, { bug: "bug", feature: "feature" }
17
+
18
+ validates :title, presence: true
19
+ validates :report_type, presence: true
20
+ validates :remote_bug_report_id, uniqueness: true, allow_nil: true
21
+ # The API requires a severity for bugs. When the host hides the picker
22
+ # (ask_severity false) the controller fills in the default before saving.
23
+ validates :severity, presence: true, if: :bug?
24
+
25
+ validate :required_responses_present
26
+ validate :screenshot_limit
27
+
28
+ scope :undismissed, -> { where(dismissed_at: nil) }
29
+ scope :resolved_and_undismissed, -> { closed.undismissed }
30
+
31
+ # Reads a single schema-field answer. Keys are always stored as strings.
32
+ def response(key)
33
+ (responses || {})[key.to_s]
34
+ end
35
+
36
+ # Merges new answers in rather than replacing, so partial updates (e.g. a
37
+ # type switch that only submits the visible fields) keep earlier answers.
38
+ def responses=(new_responses)
39
+ super((responses || {}).merge((new_responses || {}).stringify_keys))
40
+ end
41
+
42
+ # A bug's severity and a feature's `priority` field are the same "how
43
+ # important is this" rating shown in one list column.
44
+ def importance
45
+ feature? ? response("priority").presence || severity : severity
46
+ end
47
+
48
+ # Human-readable noun for user-facing copy ("bug report" / "feature request").
49
+ def type_noun
50
+ I18n.t("bug_reports_client.type_nouns.#{report_type.presence || 'bug'}")
51
+ end
52
+
53
+ private
54
+
55
+ # Enforces the schema's required fields for the selected report type.
56
+ # Checkboxes must be ticked; everything else must be present.
57
+ def required_responses_present
58
+ return if report_type.blank?
59
+
60
+ BugReportsClient.form_schema.required_fields(report_type).each do |field|
61
+ value = response(field.key)
62
+ missing = field.checkbox? ? !ActiveModel::Type::Boolean.new.cast(value) : value.blank?
63
+ errors.add(:base, I18n.t("bug_reports_client.errors.required_field", field: field.label_text)) if missing
64
+ end
65
+ end
66
+
67
+ def screenshot_limit
68
+ max = BugReportsClient.config.max_screenshots
69
+ if screenshots.count > max
70
+ errors.add(:base, I18n.t("bug_reports_client.errors.too_many_screenshots", max: max))
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,46 @@
1
+ module BugReportsClient
2
+ # A captured 500 attributed to the signed-in user who hit it. Surfaced on
3
+ # the report form ("did your problem relate to this error?") so user bug
4
+ # reports can reference the automatically-filed error issue by fingerprint.
5
+ # Events are short-lived working data - old ones are pruned on write.
6
+ class ErrorEvent < ApplicationRecord
7
+ self.table_name = "bug_report_error_events"
8
+
9
+ RETENTION = 30 * 24 * 60 * 60 # seconds; errors older than this are pruned
10
+
11
+ belongs_to :user, class_name: BugReportsClient.config.user_class
12
+
13
+ scope :recent_first, -> { order(occurred_at: :desc) }
14
+ scope :since, ->(time) { where(occurred_at: time..) }
15
+
16
+ # Records an occurrence for a user and prunes their stale events.
17
+ def self.record!(user_id:, fingerprint:, exception_class:, message:, occurred_at:, activity: nil)
18
+ where(user_id: user_id).where(occurred_at: ...(Time.current - RETENTION)).delete_all
19
+
20
+ create!(
21
+ user_id: user_id,
22
+ fingerprint: fingerprint,
23
+ exception_class: exception_class,
24
+ message: message.to_s.truncate(200),
25
+ activity: activity,
26
+ occurred_at: occurred_at
27
+ )
28
+ end
29
+
30
+ # Technical one-liner for the GitHub issue markdown - never shown to
31
+ # end users.
32
+ def summary
33
+ "#{exception_class}: #{message}".truncate(110)
34
+ end
35
+
36
+ # What the user sees on the report form: what they were doing, not the
37
+ # exception ("something went wrong while viewing invoices").
38
+ def user_facing_description
39
+ if activity.present?
40
+ I18n.t("bug_reports_client.form.related_error.option_with_activity", activity: activity)
41
+ else
42
+ I18n.t("bug_reports_client.form.related_error.option_generic")
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,15 @@
1
+ module BugReportsClient
2
+ # Included into the host app's user model (one documented line) to link
3
+ # users to their submitted reports:
4
+ #
5
+ # class User < ApplicationRecord
6
+ # include BugReportsClient::Reporter
7
+ # end
8
+ module Reporter
9
+ extend ActiveSupport::Concern
10
+
11
+ included do
12
+ has_many :bug_reports, class_name: "BugReportsClient::BugReport", dependent: :destroy
13
+ end
14
+ end
15
+ end