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
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: ddf7fc7f7b28ffe23e4a4e2965343975fc2d353887f7215e3c7de627259caa34
|
|
4
|
+
data.tar.gz: f5d7058b59536805b5c3554c7a1b34fc367a4db509fd804489bba639b153fed9
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: cc3d27ecbfb780ff73c4d13171d03ce10994c780303b4e50ead54a498b4c51725fe6b38cfb2dcd2d4f818baa875a3ac7286b0618ef082fd9d18bddb2865ef2aa
|
|
7
|
+
data.tar.gz: 46f0ce019b199b6a86901d4b932905666e28689ba7221e0e5d534a5a98b1a15567bb9353d2978a5f652b4accfdb2de4506391ea1b9a50b80239e14abf966a239
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0 (23 July 2026)
|
|
4
|
+
|
|
5
|
+
- Initial release: mountable engine with a schema-driven report form
|
|
6
|
+
(YAML-defined fields, report type cards, drag-and-drop screenshots with
|
|
7
|
+
previews), my-reports and all-reports views, submission to the companion
|
|
8
|
+
bug-reports API, signed closure webhooks (timestamped, replay-protected)
|
|
9
|
+
and dismissable resolved-report alerts.
|
|
10
|
+
- Automatic error capture: unhandled 500s become deduplicated GitHub issues,
|
|
11
|
+
attributed to the user who hit them so the report form can offer
|
|
12
|
+
"did it relate to this error?".
|
|
13
|
+
- Install and views generators, i18n-based wording, optional markdown issue
|
|
14
|
+
template with placeholder substitution.
|
data/MIT-LICENSE
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
Copyright 2026 PSA Squash Tour
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
|
4
|
+
a copy of this software and associated documentation files (the
|
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
|
9
|
+
the following conditions:
|
|
10
|
+
|
|
11
|
+
The above copyright notice and this permission notice shall be
|
|
12
|
+
included in all copies or substantial portions of the Software.
|
|
13
|
+
|
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
17
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
|
18
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
|
19
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
|
20
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
# bug_reports_client
|
|
2
|
+
|
|
3
|
+
A mountable Rails engine that adds a complete bug-and-feature reporting flow
|
|
4
|
+
to your app, backed by a central [bug-reports API](../README.md) that files
|
|
5
|
+
GitHub issues and calls back when they are closed.
|
|
6
|
+
|
|
7
|
+
What your users get:
|
|
8
|
+
|
|
9
|
+
- A polished **report form** with a bug/feature toggle, driven entirely by a
|
|
10
|
+
YAML schema you can customise per app - fields, types, required flags,
|
|
11
|
+
sections and wording.
|
|
12
|
+
- **Screenshot uploads** (Active Storage, optional).
|
|
13
|
+
- A **"My Reports"** page tracking the status of everything they've filed,
|
|
14
|
+
plus an admin-only "All Reports" view.
|
|
15
|
+
- **Resolved alerts**: when the GitHub issue is closed, a signed webhook flips
|
|
16
|
+
the local record and the reporter sees a dismissable "your report has been
|
|
17
|
+
resolved" banner.
|
|
18
|
+
|
|
19
|
+
Requirements: Rails >= 8.0, Turbo/Stimulus via importmap-rails, Tailwind CSS
|
|
20
|
+
(for the default views - or copy the views and style them however you like).
|
|
21
|
+
|
|
22
|
+
## Installation
|
|
23
|
+
|
|
24
|
+
```ruby
|
|
25
|
+
# Gemfile
|
|
26
|
+
gem "bug_reports_client", github: "Professional-Squash-Association/bug-reports", glob: "client/*.gemspec"
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
bundle install
|
|
31
|
+
bin/rails g bug_reports_client:install
|
|
32
|
+
bin/rails bug_reports_client:install:migrations && bin/rails db:migrate
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
The install generator creates `config/initializers/bug_reports_client.rb`,
|
|
36
|
+
mounts the engine at `/bug_reports`, and copies the default form schema to
|
|
37
|
+
`config/bug_report_form.yml` ready to customise. It prints the remaining
|
|
38
|
+
checklist:
|
|
39
|
+
|
|
40
|
+
```ruby
|
|
41
|
+
# app/models/user.rb
|
|
42
|
+
include BugReportsClient::Reporter
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
```erb
|
|
46
|
+
<%# somewhere in your layout %>
|
|
47
|
+
<%= link_to "Report a bug", bug_reports_client.new_bug_report_path %>
|
|
48
|
+
<%= bug_report_alerts %>
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Environment variables (or set the equivalents in the initializer):
|
|
52
|
+
|
|
53
|
+
| Variable | Purpose |
|
|
54
|
+
|---|---|
|
|
55
|
+
| `BUG_REPORT_API_URL` | Base URL of the bug-reports API, e.g. `https://bugs.example.com/api` |
|
|
56
|
+
| `BUG_REPORT_API_KEY` | This app's API token (Bearer auth) |
|
|
57
|
+
| `BUG_REPORT_WEBHOOK_SECRET` | This app's webhook secret (HMAC verification of closure callbacks) |
|
|
58
|
+
| `APP_HOST` | Public HTTPS origin of this app (callback URL + screenshot links) |
|
|
59
|
+
|
|
60
|
+
On the API side, the app needs an `ApiKey` record and an entry in
|
|
61
|
+
`config/repo_mapping.yml` mapping its `source` name to a GitHub repository.
|
|
62
|
+
|
|
63
|
+
## Configuration
|
|
64
|
+
|
|
65
|
+
Everything lives in the initializer. Only `source` is required; the rest have
|
|
66
|
+
sensible defaults:
|
|
67
|
+
|
|
68
|
+
```ruby
|
|
69
|
+
BugReportsClient.configure do |config|
|
|
70
|
+
config.source = "myapp" # must match the API key name
|
|
71
|
+
|
|
72
|
+
# Host integration (defaults shown)
|
|
73
|
+
config.parent_controller = "::ApplicationController"
|
|
74
|
+
config.current_user_method = :current_user
|
|
75
|
+
config.authenticate_method = :authenticate_user!
|
|
76
|
+
config.user_class = "User"
|
|
77
|
+
config.reporter_email_method = :email # symbol or ->(user) { ... }
|
|
78
|
+
config.reporter_name_method = :name
|
|
79
|
+
|
|
80
|
+
# Behaviour
|
|
81
|
+
config.admin_check = ->(user) { user.admin? } # gates /bug_reports/all
|
|
82
|
+
config.reporter_external = ->(user) { false } # shown on the GitHub issue
|
|
83
|
+
config.ask_severity = true # false hides the picker...
|
|
84
|
+
config.default_severity = "medium" # ...and submits this for bugs
|
|
85
|
+
config.screenshots_enabled = true # needs publicly reachable storage
|
|
86
|
+
config.max_screenshots = 5
|
|
87
|
+
|
|
88
|
+
# Connection (default from ENV as above)
|
|
89
|
+
# config.api_url / config.api_key / config.webhook_secret / config.app_host
|
|
90
|
+
# config.callback_url # override the derived callback URL
|
|
91
|
+
# config.mount_path = "/bug_reports" # if you mount somewhere else
|
|
92
|
+
# config.app_name = "My App" # {{app_name}} in issue templates
|
|
93
|
+
end
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## Customising the form
|
|
97
|
+
|
|
98
|
+
`config/bug_report_form.yml` defines the fields per report type:
|
|
99
|
+
|
|
100
|
+
```yaml
|
|
101
|
+
bug:
|
|
102
|
+
- field: what_happened
|
|
103
|
+
type: textarea # text | textarea | select | checkbox
|
|
104
|
+
required: true
|
|
105
|
+
section: the_problem # starts a new card; heading via i18n or humanised
|
|
106
|
+
rows: 4
|
|
107
|
+
- field: browser
|
|
108
|
+
type: select
|
|
109
|
+
options: [Chrome, Safari, Firefox, Other] # or {value:, label:} pairs
|
|
110
|
+
feature:
|
|
111
|
+
- field: problem
|
|
112
|
+
type: textarea
|
|
113
|
+
required: true
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Delete the file to fall back to the engine's default schema (a
|
|
117
|
+
well-rounded bug/feature form). Define only one report type and the
|
|
118
|
+
bug/feature toggle disappears.
|
|
119
|
+
|
|
120
|
+
Answers are stored in a JSON `responses` column, validated server-side from
|
|
121
|
+
the same schema (required flags, and only schema-declared keys are permitted).
|
|
122
|
+
|
|
123
|
+
### Wording
|
|
124
|
+
|
|
125
|
+
All copy is i18n under the `bug_reports_client` namespace - override any
|
|
126
|
+
subset in your own locale files, no view copying needed:
|
|
127
|
+
|
|
128
|
+
```yaml
|
|
129
|
+
en:
|
|
130
|
+
bug_reports_client:
|
|
131
|
+
new:
|
|
132
|
+
heading: "Report an issue"
|
|
133
|
+
fields:
|
|
134
|
+
what_happened:
|
|
135
|
+
label: "What happened?"
|
|
136
|
+
placeholder: "Tell us what went wrong"
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Inline `label`/`placeholder`/`help` keys in the schema win over i18n.
|
|
140
|
+
|
|
141
|
+
### GitHub issue body
|
|
142
|
+
|
|
143
|
+
By default the issue body is generated from the schema: a `## <label>` section
|
|
144
|
+
per answered field, a severity line for bugs, and screenshot links. To control
|
|
145
|
+
it yourself, create `config/bug_report_issue.md` (the install generator drops
|
|
146
|
+
an `.example` next to it) using `{{field}}` placeholders - every schema field
|
|
147
|
+
key plus `{{title}}`, `{{report_type}}`, `{{severity}}`, `{{reporter_name}}`,
|
|
148
|
+
`{{app_name}}` and `{{screenshots}}`.
|
|
149
|
+
|
|
150
|
+
### Views
|
|
151
|
+
|
|
152
|
+
The default views are plain Tailwind. To restyle:
|
|
153
|
+
|
|
154
|
+
```bash
|
|
155
|
+
bin/rails g bug_reports_client:views
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
copies them into `app/views/bug_reports_client/` where they shadow the
|
|
159
|
+
engine's per file. The form posts a stable param contract, so you can also
|
|
160
|
+
hand-write a completely custom form - anything that submits these names works:
|
|
161
|
+
|
|
162
|
+
```
|
|
163
|
+
bug_report[title]
|
|
164
|
+
bug_report[report_type] bug | feature
|
|
165
|
+
bug_report[severity] low | medium | high | critical (bugs)
|
|
166
|
+
bug_report[responses][<field_key>] one per schema field
|
|
167
|
+
bug_report[screenshots][] file uploads (optional)
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
Hosts can also override just the Turbo Stream dismissal hook
|
|
171
|
+
(`app/views/bug_reports_client/shared/_after_dismiss.turbo_stream.erb`) to
|
|
172
|
+
update their own UI - e.g. a notification badge - when an alert is dismissed.
|
|
173
|
+
|
|
174
|
+
## Styling
|
|
175
|
+
|
|
176
|
+
The engine views use plain Tailwind utilities; your app compiles the CSS, so
|
|
177
|
+
add the gem's views to your build:
|
|
178
|
+
|
|
179
|
+
**Tailwind v3** (`config/tailwind.config.js`):
|
|
180
|
+
|
|
181
|
+
```js
|
|
182
|
+
const { execSync } = require('child_process');
|
|
183
|
+
let bugReportsClient = '';
|
|
184
|
+
try { bugReportsClient = execSync('bundle show bug_reports_client').toString().trim(); } catch (e) {}
|
|
185
|
+
|
|
186
|
+
module.exports = {
|
|
187
|
+
content: [
|
|
188
|
+
'./app/views/**/*.erb',
|
|
189
|
+
// ...existing globs...
|
|
190
|
+
bugReportsClient && `${bugReportsClient}/app/views/**/*.erb`,
|
|
191
|
+
bugReportsClient && `${bugReportsClient}/app/helpers/**/*.rb`,
|
|
192
|
+
bugReportsClient && `${bugReportsClient}/app/javascript/**/*.js`,
|
|
193
|
+
].filter(Boolean),
|
|
194
|
+
}
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
**Tailwind v4** (tailwindcss-rails >= 4.x): the engine ships
|
|
198
|
+
`app/assets/tailwind/bug_reports_client/engine.css`; add to
|
|
199
|
+
`app/assets/tailwind/application.css`:
|
|
200
|
+
|
|
201
|
+
```css
|
|
202
|
+
@import "../builds/tailwind/bug_reports_client";
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
## JavaScript
|
|
206
|
+
|
|
207
|
+
Three Stimulus controllers ship with the engine and are pinned into your
|
|
208
|
+
importmap automatically: `bug-reports-client--report-type` toggles the
|
|
209
|
+
bug/feature field groups, `bug-reports-client--screenshot-dropzone` powers
|
|
210
|
+
the drag-and-drop screenshot picker with thumbnail previews (the hidden file
|
|
211
|
+
input stays the submission source, so custom forms can ignore it), and
|
|
212
|
+
`bug-reports-client--file-limit` is a standalone file-count guard for
|
|
213
|
+
hand-rolled forms. Any standard Stimulus setup that loads controllers from
|
|
214
|
+
the importmap (`eagerLoadControllersFrom` / `lazyLoadControllersFrom`) picks
|
|
215
|
+
them up with no changes.
|
|
216
|
+
|
|
217
|
+
## Automatic error capture (Sentry-style)
|
|
218
|
+
|
|
219
|
+
Opt in per host and unhandled 500s become GitHub issues, deduplicated:
|
|
220
|
+
|
|
221
|
+
```ruby
|
|
222
|
+
config.error_reporting_enabled = true
|
|
223
|
+
config.error_ignore = [] # extra exception class names to skip
|
|
224
|
+
config.error_throttle_period = 300 # seconds between posts per fingerprint
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
The engine subscribes to the Rails error reporter, so anything that would
|
|
228
|
+
render a 500 is captured; exceptions Rails maps to 4xx responses
|
|
229
|
+
(`RecordNotFound`, routing errors, etc) are skipped automatically. Errors
|
|
230
|
+
are fingerprinted by exception class + top application frame (line numbers
|
|
231
|
+
ignored, so deploys don't spawn duplicate issues), throttled through
|
|
232
|
+
`Rails.cache`, and posted in a background job so failing requests are never
|
|
233
|
+
slowed down. The API keeps one open issue per fingerprint, counting repeat
|
|
234
|
+
occurrences; when a closed error recurs, a fresh issue is filed (a
|
|
235
|
+
regression deserves new attention).
|
|
236
|
+
|
|
237
|
+
Captured errors are also attributed to the signed-in user who hit them
|
|
238
|
+
(stored locally in `bug_report_error_events`, pruned after 30 days). When
|
|
239
|
+
that user opens the report form within 24 hours, it asks "did this happen
|
|
240
|
+
alongside an error we caught?" - linking one threads the exception, time
|
|
241
|
+
and fingerprint into their report, so the GitHub issue cross-references the
|
|
242
|
+
automatically-filed error issue (search the repo for the fingerprint).
|
|
243
|
+
Existing installs: run `bin/rails bug_reports_client:install:migrations &&
|
|
244
|
+
bin/rails db:migrate` to pick up the events table.
|
|
245
|
+
|
|
246
|
+
## Webhook contract
|
|
247
|
+
|
|
248
|
+
The API POSTs closure callbacks to `<APP_HOST><mount_path>/webhook`, signed
|
|
249
|
+
with your app's webhook secret. Preferred scheme: `X-Timestamp` plus
|
|
250
|
+
`X-Signature-Timestamped: sha256=<hex>` where the HMAC-SHA256 covers
|
|
251
|
+
`"<timestamp>.<body>"` - the engine rejects timestamps older than 5 minutes
|
|
252
|
+
(replay protection). A legacy body-only `X-Signature` is also accepted when
|
|
253
|
+
no timestamp is sent. Bodies over 1 MB are rejected outright. The engine
|
|
254
|
+
verifies the signature, marks the local report closed, and returns 200. The
|
|
255
|
+
callback URL must be public HTTPS.
|
|
256
|
+
|
|
257
|
+
## Security notes
|
|
258
|
+
|
|
259
|
+
- **Screenshots are validated server-side**: content type is sniffed from
|
|
260
|
+
the file bytes (not the client-declared type), and size/count limits apply
|
|
261
|
+
(`screenshot_content_types`, `max_screenshot_size`, `max_screenshots`).
|
|
262
|
+
- **Screenshot URLs are embedded in GitHub issues** so maintainers can see
|
|
263
|
+
them - which means uploads become unauthenticated public URLs on your
|
|
264
|
+
storage service. If that's unacceptable, set `screenshots_enabled = false`
|
|
265
|
+
or serve Active Storage through expiring/signed URLs.
|
|
266
|
+
- **Issue bodies contain verbatim user text.** Treat report content on the
|
|
267
|
+
receiving side (GitHub, the central API) as untrusted: user answers can
|
|
268
|
+
contain markdown, links and @mentions.
|
|
269
|
+
- **The issue template path is trusted config.** `issue_template_path` (and
|
|
270
|
+
`config/bug_report_issue.md`) is read from disk verbatim - only ever point
|
|
271
|
+
it at a file you control.
|
|
272
|
+
|
|
273
|
+
## Testing
|
|
274
|
+
|
|
275
|
+
The engine has its own test suite with a dummy host app:
|
|
276
|
+
|
|
277
|
+
```bash
|
|
278
|
+
cd client
|
|
279
|
+
bundle install
|
|
280
|
+
bundle exec rake test
|
|
281
|
+
```
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/* Tailwind v4 hosts only (tailwindcss-rails >= 4.x engine support).
|
|
2
|
+
Tells the host's Tailwind build to scan the engine's views and JavaScript
|
|
3
|
+
for utility classes. Tailwind v3 hosts instead add the gem's paths to their
|
|
4
|
+
tailwind.config.js content globs - see the gem README. */
|
|
5
|
+
@source "../../../views/bug_reports_client/**/*.erb";
|
|
6
|
+
@source "../../../helpers/bug_reports_client/**/*.rb";
|
|
7
|
+
@source "../../../javascript/bug_reports_client/**/*.js";
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
module BugReportsClient
|
|
2
|
+
# Inherits from the host app's ApplicationController (configurable) so the
|
|
3
|
+
# engine's pages get the host layout, session handling and flash styling for
|
|
4
|
+
# free. Authentication is enforced through the configured method name, which
|
|
5
|
+
# covers both Devise hosts and bespoke session auth.
|
|
6
|
+
class ApplicationController < BugReportsClient.config.parent_controller.constantize
|
|
7
|
+
# Host layouts reference host route helpers; delegate the ones the engine
|
|
8
|
+
# doesn't define to the host app so layouts render unmodified.
|
|
9
|
+
helper BugReportsClient::MainAppRoutes
|
|
10
|
+
|
|
11
|
+
# Only enforce our own authentication when the host asks us to. Hosts that
|
|
12
|
+
# already authenticate globally (a `before_action :authenticate_user!` in
|
|
13
|
+
# their ApplicationController, which we inherit) set
|
|
14
|
+
# config.authenticate_method = nil to avoid running auth twice per request.
|
|
15
|
+
before_action :authenticate_reporter!, if: -> { BugReportsClient.config.authenticate_method.present? }
|
|
16
|
+
|
|
17
|
+
helper_method :bug_reports_current_user, :bug_reports_admin?
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def authenticate_reporter!
|
|
22
|
+
send(BugReportsClient.config.authenticate_method)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# The signed-in user, via the host's configured accessor.
|
|
26
|
+
def bug_reports_current_user
|
|
27
|
+
send(BugReportsClient.config.current_user_method)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def bug_reports_admin?
|
|
31
|
+
user = bug_reports_current_user
|
|
32
|
+
user.present? && BugReportsClient.config.admin?(user)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
module BugReportsClient
|
|
2
|
+
# Handles report submission, editing, listing and dismissal of resolved
|
|
3
|
+
# alerts. Reports are saved locally (screenshots via Active Storage), then
|
|
4
|
+
# submitted to the central bug-reports API which files a GitHub issue; the
|
|
5
|
+
# remote ID is stored so closure webhooks can find the local record.
|
|
6
|
+
class BugReportsController < ApplicationController
|
|
7
|
+
PER_PAGE = 20
|
|
8
|
+
|
|
9
|
+
before_action :set_bug_report, only: %i[edit update]
|
|
10
|
+
before_action :authorize_bug_report_access!, only: %i[edit update]
|
|
11
|
+
before_action :require_admin!, only: %i[all]
|
|
12
|
+
|
|
13
|
+
# GET / (engine root) - the current user's reports
|
|
14
|
+
def index
|
|
15
|
+
reports = BugReport.where(user: bug_reports_current_user)
|
|
16
|
+
@bug_reports = paginate(filtered(reports).order(created_at: :desc))
|
|
17
|
+
|
|
18
|
+
@counts = status_counts(reports)
|
|
19
|
+
@type_counts = type_counts(reports)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# GET /all (admins via config.admin_check) - every report from this app
|
|
23
|
+
def all
|
|
24
|
+
@bug_reports = paginate(filtered(BugReport.includes(:user)).order(created_at: :desc))
|
|
25
|
+
|
|
26
|
+
@counts = status_counts(BugReport)
|
|
27
|
+
@type_counts = type_counts(BugReport)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# GET /new
|
|
31
|
+
def new
|
|
32
|
+
@bug_report = BugReport.new
|
|
33
|
+
@recent_error_events = recent_error_events
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# GET /:id/edit - form for open (editable) and closed (read-only) reports
|
|
37
|
+
def edit
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# POST /
|
|
41
|
+
# Saves the report and screenshots locally, submits to the API, then
|
|
42
|
+
# stores the returned remote ID. A failed API call rolls the local record
|
|
43
|
+
# back so users can retry without creating orphans.
|
|
44
|
+
def create
|
|
45
|
+
@bug_report = BugReport.new(bug_report_params)
|
|
46
|
+
@bug_report.user = bug_reports_current_user
|
|
47
|
+
apply_default_severity(@bug_report)
|
|
48
|
+
link_related_error(@bug_report)
|
|
49
|
+
@recent_error_events = recent_error_events
|
|
50
|
+
|
|
51
|
+
# Screenshot files are validated server-side (type sniffed, size and
|
|
52
|
+
# count checked) BEFORE anything is attached or saved.
|
|
53
|
+
unless validate_screenshots(@bug_report, submitted_screenshots)
|
|
54
|
+
render :new, status: :unprocessable_entity
|
|
55
|
+
return
|
|
56
|
+
end
|
|
57
|
+
attach_screenshots(@bug_report)
|
|
58
|
+
|
|
59
|
+
unless @bug_report.save
|
|
60
|
+
render :new, status: :unprocessable_entity
|
|
61
|
+
return
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
result = ApiClient.new.create_bug_report(
|
|
65
|
+
title: @bug_report.title,
|
|
66
|
+
description: DescriptionBuilder.new(@bug_report).build,
|
|
67
|
+
severity: @bug_report.severity,
|
|
68
|
+
report_type: @bug_report.report_type,
|
|
69
|
+
reporter_email: BugReportsClient.config.reporter_email_for(bug_reports_current_user),
|
|
70
|
+
reporter_name: BugReportsClient.config.reporter_name_for(bug_reports_current_user),
|
|
71
|
+
reporter_external: BugReportsClient.config.external_reporter?(bug_reports_current_user)
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
if result.success?
|
|
75
|
+
@bug_report.update!(remote_bug_report_id: result.data["id"])
|
|
76
|
+
redirect_to bug_reports_path, notice: t("bug_reports_client.flashes.created", type_noun: @bug_report.type_noun.capitalize)
|
|
77
|
+
else
|
|
78
|
+
# Surface the failure in the form's error box (which scrolls itself
|
|
79
|
+
# into view) rather than a top-of-page flash the user may never see.
|
|
80
|
+
@bug_report.destroy
|
|
81
|
+
@bug_report = BugReport.new
|
|
82
|
+
@bug_report.errors.add(:base, result.message)
|
|
83
|
+
render :new, status: :unprocessable_entity
|
|
84
|
+
end
|
|
85
|
+
rescue ApiClient::ApiError => e
|
|
86
|
+
@bug_report.destroy if @bug_report&.persisted?
|
|
87
|
+
@bug_report = BugReport.new
|
|
88
|
+
@bug_report.errors.add(:base, t("bug_reports_client.flashes.submit_failed"))
|
|
89
|
+
Rails.logger.error "BugReportsClient: submission failed: #{e.message}"
|
|
90
|
+
render :new, status: :unprocessable_entity
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# PATCH /:id
|
|
94
|
+
# Two cases: dismissing a resolved alert (no bug_report params), or
|
|
95
|
+
# editing an open report (re-syncs the remote issue).
|
|
96
|
+
def update
|
|
97
|
+
return dismiss_bug_report if params[:bug_report].blank?
|
|
98
|
+
|
|
99
|
+
unless @bug_report.open?
|
|
100
|
+
redirect_to edit_bug_report_path(@bug_report), alert: t("bug_reports_client.flashes.only_open_editable", type_noun: @bug_report.type_noun)
|
|
101
|
+
return
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# The report type is locked once submitted - the remote issue and its
|
|
105
|
+
# labels are already filed as that type.
|
|
106
|
+
@bug_report.assign_attributes(bug_report_params.except(:report_type))
|
|
107
|
+
apply_default_severity(@bug_report)
|
|
108
|
+
|
|
109
|
+
# Attaching to a persisted record writes immediately (bypassing model
|
|
110
|
+
# validations), so the new files must be validated before anything is
|
|
111
|
+
# saved or purged.
|
|
112
|
+
new_screenshots = submitted_screenshots
|
|
113
|
+
unless validate_screenshots(@bug_report, new_screenshots)
|
|
114
|
+
render :edit, status: :unprocessable_entity
|
|
115
|
+
return
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
if @bug_report.save
|
|
119
|
+
# New uploads replace the existing set; editing without choosing any
|
|
120
|
+
# files leaves the current screenshots alone.
|
|
121
|
+
if new_screenshots.any?
|
|
122
|
+
@bug_report.screenshots.purge
|
|
123
|
+
@bug_report.screenshots.attach(new_screenshots)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
if @bug_report.remote_bug_report_id.present?
|
|
127
|
+
ApiClient.new.update_bug_report(
|
|
128
|
+
@bug_report.remote_bug_report_id,
|
|
129
|
+
title: @bug_report.title,
|
|
130
|
+
description: DescriptionBuilder.new(@bug_report).build,
|
|
131
|
+
severity: @bug_report.severity,
|
|
132
|
+
report_type: @bug_report.report_type
|
|
133
|
+
)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
redirect_to edit_bug_report_path(@bug_report), notice: t("bug_reports_client.flashes.updated", type_noun: @bug_report.type_noun.capitalize)
|
|
137
|
+
else
|
|
138
|
+
render :edit, status: :unprocessable_entity
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
private
|
|
143
|
+
|
|
144
|
+
def set_bug_report
|
|
145
|
+
@bug_report = BugReport.find(params[:id])
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def authorize_bug_report_access!
|
|
149
|
+
unless @bug_report.user_id == bug_reports_current_user.id || bug_reports_admin?
|
|
150
|
+
head :forbidden
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def require_admin!
|
|
155
|
+
unless bug_reports_admin?
|
|
156
|
+
redirect_to bug_reports_path, alert: t("bug_reports_client.flashes.not_permitted")
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Only the SELECTED type's schema field keys are permitted into
|
|
161
|
+
# responses, so a tampered form can't write arbitrary data or smuggle the
|
|
162
|
+
# other type's answers in. Screenshots are permitted (silencing
|
|
163
|
+
# unpermitted-parameter noise) but excluded from mass assignment -
|
|
164
|
+
# attachment is handled explicitly below.
|
|
165
|
+
def bug_report_params
|
|
166
|
+
report_type = @bug_report&.persisted? ? @bug_report.report_type : params.dig(:bug_report, :report_type)
|
|
167
|
+
|
|
168
|
+
params.require(:bug_report).permit(
|
|
169
|
+
:title, :severity, :report_type, :related_error_event_id,
|
|
170
|
+
screenshots: [],
|
|
171
|
+
responses: BugReportsClient.form_schema.field_keys_for(report_type)
|
|
172
|
+
).except(:screenshots, :related_error_event_id)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# Captured 500s this user hit recently, offered on the form as "did your
|
|
176
|
+
# problem relate to this error?".
|
|
177
|
+
def recent_error_events
|
|
178
|
+
ErrorEvent.where(user: bug_reports_current_user).since(24.hours.ago).recent_first.limit(5)
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# If the reporter linked one of their captured errors, thread its details
|
|
182
|
+
# into the report (rendered into the GitHub issue by DescriptionBuilder,
|
|
183
|
+
# where the fingerprint cross-references the auto-filed error issue).
|
|
184
|
+
# Scoped to the current user so ids can't be guessed across accounts.
|
|
185
|
+
def link_related_error(report)
|
|
186
|
+
event_id = params.dig(:bug_report, :related_error_event_id)
|
|
187
|
+
return if event_id.blank?
|
|
188
|
+
|
|
189
|
+
event = ErrorEvent.where(user: bug_reports_current_user).find_by(id: event_id)
|
|
190
|
+
return unless event
|
|
191
|
+
|
|
192
|
+
# Full technical detail for the issue - the user only ever saw the
|
|
193
|
+
# human description.
|
|
194
|
+
details = [ event.summary ]
|
|
195
|
+
details << "while #{event.activity}" if event.activity.present?
|
|
196
|
+
report.responses = {
|
|
197
|
+
"related_error" => "#{details.join(' - ')} (fingerprint `#{event.fingerprint}`, at #{event.occurred_at.iso8601})"
|
|
198
|
+
}
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# The submitted screenshot files, minus the blank entry a multiple file
|
|
202
|
+
# input always includes. An edit without new uploads therefore submits
|
|
203
|
+
# no files and existing screenshots are left untouched.
|
|
204
|
+
def submitted_screenshots
|
|
205
|
+
return [] unless BugReportsClient.config.screenshots_enabled
|
|
206
|
+
|
|
207
|
+
Array(params.dig(:bug_report, :screenshots)).reject(&:blank?)
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Consumer-facing hosts hide the severity picker; the API still requires
|
|
211
|
+
# a severity for bugs, so fill in the configured default.
|
|
212
|
+
def apply_default_severity(report)
|
|
213
|
+
return if BugReportsClient.config.ask_severity
|
|
214
|
+
report.severity = BugReportsClient.config.default_severity if report.bug?
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def attach_screenshots(report)
|
|
218
|
+
files = submitted_screenshots
|
|
219
|
+
report.screenshots.attach(files) if files.any?
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# Server-side screenshot validation, run before any attach: count within
|
|
223
|
+
# the limit, size within bounds, and the content type sniffed from the
|
|
224
|
+
# file bytes (Marcel) rather than trusting the client-declared type.
|
|
225
|
+
# Adds errors to the record and returns false when anything fails.
|
|
226
|
+
def validate_screenshots(report, files)
|
|
227
|
+
config = BugReportsClient.config
|
|
228
|
+
messages = []
|
|
229
|
+
|
|
230
|
+
if files.size > config.max_screenshots
|
|
231
|
+
messages << t("bug_reports_client.errors.too_many_screenshots", max: config.max_screenshots)
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
files.each do |file|
|
|
235
|
+
detected_type = Marcel::MimeType.for(file.tempfile, name: file.original_filename, declared_type: file.content_type)
|
|
236
|
+
unless config.screenshot_content_types.include?(detected_type)
|
|
237
|
+
messages << t("bug_reports_client.errors.screenshot_type", filename: file.original_filename)
|
|
238
|
+
end
|
|
239
|
+
if file.size > config.max_screenshot_size
|
|
240
|
+
messages << t("bug_reports_client.errors.screenshot_size", filename: file.original_filename, max_mb: config.max_screenshot_size / (1024 * 1024))
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
return true if messages.empty?
|
|
245
|
+
|
|
246
|
+
report.validate
|
|
247
|
+
messages.each { |message| report.errors.add(:base, message) }
|
|
248
|
+
false
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# Minimal offset/limit pagination - deliberately no pagy/kaminari
|
|
252
|
+
# dependency. Sets @page/@total_pages for the shared pagination partial.
|
|
253
|
+
def paginate(scope)
|
|
254
|
+
@page = [ params[:page].to_i, 1 ].max
|
|
255
|
+
@total_pages = [ (scope.count / PER_PAGE.to_f).ceil, 1 ].max
|
|
256
|
+
@page = @total_pages if @page > @total_pages
|
|
257
|
+
scope.offset((@page - 1) * PER_PAGE).limit(PER_PAGE)
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
# Applies the active status and type filters to a bug reports relation.
|
|
261
|
+
def filtered(scope)
|
|
262
|
+
scope = scope.where(status: params[:status]) if params[:status].present?
|
|
263
|
+
scope = scope.where(report_type: params[:report_type]) if params[:report_type].present?
|
|
264
|
+
scope
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
# Status counts within the currently-selected type, so the tabs stay in
|
|
268
|
+
# sync with the type filter (and vice versa for type_counts).
|
|
269
|
+
def status_counts(scope)
|
|
270
|
+
scope = scope.where(report_type: params[:report_type]) if params[:report_type].present?
|
|
271
|
+
{ all: scope.count, open: scope.open.count, closed: scope.closed.count }
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def type_counts(scope)
|
|
275
|
+
scope = scope.where(status: params[:status]) if params[:status].present?
|
|
276
|
+
{ all: scope.count, bug: scope.bug.count, feature: scope.feature.count }
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
# Dismisses a resolved-report alert. Removes the alert elements via Turbo
|
|
280
|
+
# Streams and renders the overridable _after_dismiss hook so hosts can
|
|
281
|
+
# update their own UI (e.g. a notification badge) in the same response.
|
|
282
|
+
def dismiss_bug_report
|
|
283
|
+
# Column write, not update!: dismissing must work even for reports that
|
|
284
|
+
# would no longer pass validation (e.g. after a form-schema change).
|
|
285
|
+
@bug_report.update_columns(dismissed_at: Time.current, updated_at: Time.current)
|
|
286
|
+
|
|
287
|
+
@remaining = BugReport.where(user: bug_reports_current_user).resolved_and_undismissed
|
|
288
|
+
|
|
289
|
+
respond_to do |format|
|
|
290
|
+
format.turbo_stream do
|
|
291
|
+
streams = [ turbo_stream.remove("bug_report_#{@bug_report.id}") ]
|
|
292
|
+
streams << turbo_stream.remove("bug_report_alerts") if @remaining.empty?
|
|
293
|
+
streams << render_to_string(partial: "bug_reports_client/shared/after_dismiss", formats: [ :turbo_stream ])
|
|
294
|
+
|
|
295
|
+
render turbo_stream: streams
|
|
296
|
+
end
|
|
297
|
+
format.html do
|
|
298
|
+
redirect_to fallback_root_path, notice: t("bug_reports_client.flashes.dismissed", type_noun: @bug_report.type_noun.capitalize)
|
|
299
|
+
end
|
|
300
|
+
end
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
# Host root if one exists, otherwise the engine's own index.
|
|
304
|
+
def fallback_root_path
|
|
305
|
+
main_app.respond_to?(:root_path) ? main_app.root_path : bug_reports_path
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
end
|