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,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,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
|