ticket 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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: a3f38c0710642e9e5f71760700c87eb76fab7185e6232791221e06b400871701
4
+ data.tar.gz: 560960719fff0060ba693ade1bf7f7ea860f8a21247ab1ce22e7d00fa5d38eee
5
+ SHA512:
6
+ metadata.gz: 73ad1d85a11a898bed9f6bf0d8c226bd67d904e3808aeb2ee954e9be983e796067a70f69485e7b7f58cb6b531e1ec69399a88d40ea9c9f23263a20fcb46b687b
7
+ data.tar.gz: c04307fd9663e4aaf0808c1925f8e8b9fd811682c8d8b288a71eee032136b362e3aeb131e4347a5b44d474cc2e5d20de5e1a405ee418a8aa4fa698b96a3d5053
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright TODO: Write your name
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,76 @@
1
+ # Ticket
2
+
3
+ A feedback button for our Rails apps. The people using an app report a bug, an idea or a question, with a screenshot if they want, without a GitHub account. Each report becomes an issue in that app's repository.
4
+
5
+ - A small **Feedback** button in the corner of every page, for signed-in people only.
6
+ - They pick a kind, write what happened, and can capture the current tab, paste, drop or choose an image.
7
+ - The page, the browser, the app version, the Rails request id and the last JavaScript errors are sent with it.
8
+ - The report is saved in the app's database first, then a job files the issue. Nothing is lost if GitHub is down; a refused token marks the report `failed` with the reason.
9
+ - The issue links to the screenshot, which is served by the app behind its own sign-in.
10
+
11
+ The widget is a plain JavaScript custom element in a shadow root, served by the app's asset pipeline. It does not touch the app's JavaScript bundle, Stimulus, Tailwind or CSS. Its text is in French and English, following `I18n.locale`.
12
+
13
+ ## Install in an app
14
+
15
+ ```ruby
16
+ # Gemfile
17
+ gem "ticket", github: "boxprod/ticket"
18
+ ```
19
+
20
+ ```sh
21
+ bundle install
22
+ bin/rails ticket:install:migrations db:migrate
23
+ ```
24
+
25
+ ```ruby
26
+ # config/routes.rb
27
+ mount Ticket::Engine => "/ticket"
28
+ ```
29
+
30
+ ```ruby
31
+ # config/initializers/ticket.rb
32
+ Ticket.configure do |config|
33
+ config.repository = "boxprod/the-app"
34
+ config.github_token = Rails.application.credentials.dig(:ticket, :github_token)
35
+ config.current_reporter = -> { Current.user } # nil hides the button and refuses reports
36
+ end
37
+ ```
38
+
39
+ ```erb
40
+ <%# app/views/layouts/application.html.erb, at the end of <body> %>
41
+ <%= ticket_widget %>
42
+ ```
43
+
44
+ Without a token, reports are kept in `ticket_reports` but not sent: that is what happens in development.
45
+
46
+ ### Options
47
+
48
+ | Option | Default | |
49
+ |---|---|---|
50
+ | `repository` | — | `"owner/name"` |
51
+ | `github_token` | — | Fine-grained token with **Issues: read and write** on the repository |
52
+ | `current_reporter` | `-> { nil }` | Run in the controller and the view |
53
+ | `reporter_name` | `name`, then `email_address` | Gets the reporter, returns the name shown in the issue |
54
+ | `labels` | `["feedback"]` | The kind (`bug`, `idea`, `question`) is added |
55
+ | `parent_controller` | `"::ApplicationController"` | Its authentication applies to the engine |
56
+ | `app_version` | `KAMAL_VERSION` or `GIT_REVISION` | String or lambda |
57
+ | `max_screenshot_size` | 8 MB | |
58
+
59
+ ### The GitHub token
60
+
61
+ Create a fine-grained personal access token (GitHub → Settings → Developer settings) with **boxprod** as its resource owner (the organization must allow fine-grained tokens: Settings → Personal access tokens), limited to the app's repository, with **Issues: read and write** and nothing else. Issues are opened in the name of the token's owner; the reporter's name is in the body. If GitHub refuses the labels, the issue is filed without them.
62
+
63
+ ### Reports that were not sent
64
+
65
+ ```sh
66
+ bin/rails ticket:redeliver
67
+ ```
68
+
69
+ ## Development
70
+
71
+ ```sh
72
+ bundle install
73
+ bin/rails db:migrate
74
+ bin/rails test
75
+ CHROMIUM=/usr/bin/chromium bin/rails test:system # the widget in headless Chromium, tab capture included
76
+ ```
data/Rakefile ADDED
@@ -0,0 +1,6 @@
1
+ require "bundler/setup"
2
+
3
+ APP_RAKEFILE = File.expand_path("test/dummy/Rakefile", __dir__)
4
+ load "rails/tasks/engine.rake"
5
+
6
+ require "bundler/gem_tasks"
@@ -0,0 +1,301 @@
1
+ // The feedback button: a custom element in a shadow root, so neither the host's CSS nor its
2
+ // JavaScript bundle is involved. Served as is by the host's asset pipeline.
3
+
4
+ const MAX_WIDTH = 2560
5
+ const MAX_ERRORS = 10
6
+
7
+ // Kept at module level: Turbo replaces the element on every visit, the draft survives it.
8
+ const draft = { open: false, kind: "bug", description: "", screenshot: null }
9
+ const errors = []
10
+ const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
11
+
12
+ function remember(message) {
13
+ errors.push(`${new Date().toISOString()} ${message}`.slice(0, 500))
14
+ if (errors.length > MAX_ERRORS) errors.shift()
15
+ }
16
+
17
+ window.addEventListener("error", (event) => remember(`${event.message} (${event.filename}:${event.lineno})`))
18
+ window.addEventListener("unhandledrejection", (event) => remember(`Unhandled rejection: ${event.reason}`))
19
+
20
+ const STYLE = `
21
+ :host { all: initial; font: 13px/1.4 system-ui, -apple-system, "Segoe UI", sans-serif; color: #18181b;
22
+ --border: #d4d4d8; --muted: #71717a; --surface: #fff; --soft: #f4f4f5; --text: #18181b; }
23
+ :host([hidden]) { display: none !important; }
24
+ * { box-sizing: border-box; font: inherit; }
25
+ [hidden] { display: none !important; }
26
+ .toggle { position: fixed; right: 16px; bottom: 16px; z-index: 2147483000; padding: 6px 12px;
27
+ border: 1px solid var(--border); border-radius: 6px; background: var(--surface); color: var(--text);
28
+ box-shadow: 0 1px 2px rgb(0 0 0 / .08); cursor: pointer; }
29
+ .toggle:hover { background: var(--soft); }
30
+ .panel { position: fixed; right: 16px; bottom: 56px; z-index: 2147483001; width: min(380px, calc(100vw - 32px));
31
+ max-height: calc(100vh - 72px); overflow: auto; padding: 14px; border: 1px solid var(--border);
32
+ border-radius: 8px; background: var(--surface); color: var(--text); box-shadow: 0 8px 24px rgb(0 0 0 / .12); }
33
+ header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px; }
34
+ h2 { margin: 0; font-weight: 600; font-size: 14px; }
35
+ button { cursor: pointer; }
36
+ .close { border: 0; background: none; color: var(--muted); font-size: 18px; line-height: 1; padding: 2px 4px; }
37
+ fieldset { border: 0; padding: 0; margin: 0 0 10px; display: flex; flex-direction: column; gap: 4px; }
38
+ label.kind { display: flex; align-items: center; gap: 6px; }
39
+ label.kind input { margin: 0; }
40
+ textarea { width: 100%; min-height: 110px; resize: vertical; padding: 8px; border: 1px solid var(--border);
41
+ border-radius: 6px; background: var(--surface); color: var(--text); }
42
+ textarea:focus { outline: 2px solid #3f3f46; outline-offset: -1px; }
43
+ .hint { margin: 4px 0 10px; color: var(--muted); font-size: 12px; }
44
+ .shot { border: 1px dashed var(--border); border-radius: 6px; padding: 8px; margin-bottom: 12px; }
45
+ .shot.over { background: var(--soft); }
46
+ .shot-actions { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
47
+ .shot img { display: block; max-width: 100%; max-height: 180px; margin: 0 auto 6px; border: 1px solid var(--border); }
48
+ .secondary { padding: 4px 8px; border: 1px solid var(--border); border-radius: 6px; background: var(--surface); color: var(--text); }
49
+ .secondary:hover { background: var(--soft); }
50
+ .footer { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
51
+ .message { color: var(--muted); font-size: 12px; }
52
+ .message.error { color: #b91c1c; }
53
+ .submit { padding: 6px 14px; border: 1px solid #18181b; border-radius: 6px; background: #18181b; color: #fff; }
54
+ .submit:disabled { opacity: .5; cursor: default; }
55
+ .done p { margin: 0 0 12px; }
56
+ `
57
+
58
+ class TicketWidget extends HTMLElement {
59
+ connectedCallback() {
60
+ if (this.shadowRoot) return
61
+
62
+ this.strings = JSON.parse(this.dataset.strings || "{}")
63
+ this.attachShadow({ mode: "open" })
64
+ this.render()
65
+ this.restore()
66
+ }
67
+
68
+ t(key) {
69
+ return key.split(".").reduce((value, part) => value?.[part], this.strings) ?? key
70
+ }
71
+
72
+ render() {
73
+ const kinds = ["bug", "idea", "question"].map((kind) => `
74
+ <label class="kind"><input type="radio" name="kind" value="${kind}"> ${this.escape(this.t(`kinds.${kind}`))}</label>`).join("")
75
+
76
+ this.shadowRoot.innerHTML = `
77
+ <style>${STYLE}</style>
78
+ <button type="button" class="toggle" part="toggle" aria-expanded="false">${this.escape(this.t("button"))}</button>
79
+ <section class="panel" role="dialog" aria-label="${this.escape(this.t("title"))}" hidden>
80
+ <header>
81
+ <h2>${this.escape(this.t("title"))}</h2>
82
+ <button type="button" class="close" aria-label="${this.escape(this.t("close"))}">×</button>
83
+ </header>
84
+ <form class="form" novalidate>
85
+ <fieldset>${kinds}</fieldset>
86
+ <textarea name="description" required placeholder="${this.escape(this.t("description"))}"></textarea>
87
+ <p class="hint">${this.escape(this.t("description_hint"))}</p>
88
+ <div class="shot">
89
+ <img alt="" hidden>
90
+ <div class="shot-actions">
91
+ <button type="button" class="secondary capture">${this.escape(this.t("capture"))}</button>
92
+ <button type="button" class="secondary choose">${this.escape(this.t("choose"))}</button>
93
+ <button type="button" class="secondary remove" hidden>${this.escape(this.t("remove"))}</button>
94
+ <span class="message paste-hint">${this.escape(this.t("paste_hint"))}</span>
95
+ </div>
96
+ <input type="file" accept="image/png,image/jpeg,image/webp" hidden>
97
+ </div>
98
+ <div class="footer">
99
+ <span class="message status" role="status"></span>
100
+ <button type="submit" class="submit">${this.escape(this.t("submit"))}</button>
101
+ </div>
102
+ </form>
103
+ <div class="done" hidden>
104
+ <p>${this.escape(this.t("sent"))}</p>
105
+ <div class="footer">
106
+ <button type="button" class="secondary another">${this.escape(this.t("another"))}</button>
107
+ <button type="button" class="secondary close-done">${this.escape(this.t("close"))}</button>
108
+ </div>
109
+ </div>
110
+ </section>`
111
+
112
+ const $ = (selector) => this.shadowRoot.querySelector(selector)
113
+ this.els = {
114
+ toggle: $(".toggle"), panel: $(".panel"), form: $(".form"), done: $(".done"),
115
+ description: $("textarea"), image: $(".shot img"), shot: $(".shot"), file: $("input[type=file]"),
116
+ capture: $(".capture"), remove: $(".remove"), status: $(".status"), submit: $(".submit")
117
+ }
118
+
119
+ if (!navigator.mediaDevices?.getDisplayMedia) this.els.capture.hidden = true
120
+
121
+ this.els.toggle.addEventListener("click", () => this.setOpen(!draft.open))
122
+ $(".close").addEventListener("click", () => this.setOpen(false))
123
+ $(".close-done").addEventListener("click", () => { this.reset(); this.setOpen(false) })
124
+ $(".another").addEventListener("click", () => this.reset())
125
+ $(".choose").addEventListener("click", () => this.els.file.click())
126
+ this.els.capture.addEventListener("click", () => this.capture())
127
+ this.els.remove.addEventListener("click", () => this.setScreenshot(null))
128
+ this.els.file.addEventListener("change", () => this.takeFile(this.els.file.files[0]))
129
+ this.els.description.addEventListener("input", () => { draft.description = this.els.description.value })
130
+ this.shadowRoot.querySelectorAll("input[name=kind]").forEach((input) =>
131
+ input.addEventListener("change", () => { draft.kind = input.value }))
132
+ this.els.form.addEventListener("submit", (event) => { event.preventDefault(); this.submit() })
133
+
134
+ this.els.shot.addEventListener("dragover", (event) => { event.preventDefault(); this.els.shot.classList.add("over") })
135
+ this.els.shot.addEventListener("dragleave", () => this.els.shot.classList.remove("over"))
136
+ this.els.shot.addEventListener("drop", (event) => {
137
+ event.preventDefault()
138
+ this.els.shot.classList.remove("over")
139
+ this.takeFile(event.dataTransfer.files[0])
140
+ })
141
+
142
+ // What is typed or pasted in the panel stays in it. Seen from the page, the focus is on
143
+ // <ticket-widget>, not on a field, so the page's own shortcuts would take these keys.
144
+ for (const type of ["keydown", "keyup", "keypress", "paste", "copy", "cut"]) {
145
+ this.els.panel.addEventListener(type, (event) => event.stopPropagation())
146
+ }
147
+ this.els.panel.addEventListener("keydown", (event) => {
148
+ if (event.key === "Escape") { this.setOpen(false); this.els.toggle.focus() }
149
+ if (event.key === "Enter" && (event.metaKey || event.ctrlKey) && !this.els.form.hidden) this.submit()
150
+ })
151
+ this.els.panel.addEventListener("paste", (event) => {
152
+ const file = [...(event.clipboardData?.files || [])].find((item) => item.type.startsWith("image/"))
153
+ if (file) { event.preventDefault(); this.takeFile(file) }
154
+ })
155
+ }
156
+
157
+ restore() {
158
+ this.els.description.value = draft.description
159
+ this.shadowRoot.querySelector(`input[value=${draft.kind}]`).checked = true
160
+ this.setScreenshot(draft.screenshot)
161
+ this.setOpen(draft.open, { focus: false })
162
+ }
163
+
164
+ setOpen(open, { focus = true } = {}) {
165
+ draft.open = open
166
+ this.els.panel.hidden = !open
167
+ this.els.toggle.setAttribute("aria-expanded", String(open))
168
+ if (open && focus) this.els.description.focus()
169
+ }
170
+
171
+ setScreenshot(blob) {
172
+ if (this.previewUrl) URL.revokeObjectURL(this.previewUrl)
173
+ this.previewUrl = null
174
+ draft.screenshot = blob
175
+
176
+ if (blob) {
177
+ this.previewUrl = URL.createObjectURL(blob)
178
+ this.els.image.src = this.previewUrl
179
+ } else {
180
+ this.els.image.removeAttribute("src")
181
+ this.els.file.value = ""
182
+ }
183
+ this.els.image.hidden = !blob
184
+ this.els.remove.hidden = !blob
185
+ }
186
+
187
+ async takeFile(file) {
188
+ if (!file || !file.type.startsWith("image/")) return
189
+ try {
190
+ const bitmap = await createImageBitmap(file)
191
+ this.setScreenshot(await this.encode(bitmap, bitmap.width, bitmap.height))
192
+ bitmap.close()
193
+ this.showStatus("")
194
+ } catch {
195
+ this.showStatus(this.t("capture_failed"), true)
196
+ }
197
+ }
198
+
199
+ // The current tab, picked in the browser's own dialog. The widget steps aside once sharing starts:
200
+ // a still page sends frames only when it repaints, and hiding the widget is that repaint.
201
+ async capture() {
202
+ let stream
203
+ try {
204
+ stream = await navigator.mediaDevices.getDisplayMedia({
205
+ video: { displaySurface: "browser" }, audio: false, preferCurrentTab: true, selfBrowserSurface: "include"
206
+ })
207
+ this.hidden = true
208
+ const video = document.createElement("video")
209
+ video.muted = true
210
+ video.srcObject = stream
211
+ await Promise.race([video.play(), wait(3000).then(() => { throw new Error("no frame") })])
212
+ // Let the browser's sharing bar leave the picture.
213
+ await wait(400)
214
+ await new Promise((resolve) => { video.requestVideoFrameCallback?.(resolve); setTimeout(resolve, 300) })
215
+ if (!video.videoWidth) throw new Error("no frame")
216
+ this.setScreenshot(await this.encode(video, video.videoWidth, video.videoHeight))
217
+ this.showStatus("")
218
+ } catch (error) {
219
+ if (error?.name !== "NotAllowedError") this.showStatus(this.t("capture_failed"), true)
220
+ } finally {
221
+ stream?.getTracks().forEach((track) => track.stop())
222
+ this.hidden = false
223
+ }
224
+ }
225
+
226
+ async encode(source, width, height) {
227
+ const scale = Math.min(1, MAX_WIDTH / width)
228
+ const canvas = document.createElement("canvas")
229
+ canvas.width = Math.round(width * scale)
230
+ canvas.height = Math.round(height * scale)
231
+ canvas.getContext("2d").drawImage(source, 0, 0, canvas.width, canvas.height)
232
+ return new Promise((resolve, reject) =>
233
+ canvas.toBlob((blob) => (blob ? resolve(blob) : reject(new Error("encode"))), "image/webp", 0.9))
234
+ }
235
+
236
+ async submit() {
237
+ if (!this.els.description.value.trim()) {
238
+ this.els.description.focus()
239
+ return
240
+ }
241
+
242
+ const body = new FormData()
243
+ body.append("kind", draft.kind)
244
+ body.append("description", this.els.description.value)
245
+ body.append("page_url", location.href)
246
+ body.append("page_title", document.title)
247
+ if (this.dataset.requestId) body.append("page_request_id", this.dataset.requestId)
248
+ body.append("browser[viewport]", `${innerWidth}×${innerHeight}`)
249
+ body.append("browser[screen]", `${screen.width}×${screen.height} @${devicePixelRatio}x`)
250
+ body.append("browser[language]", navigator.language)
251
+ body.append("browser[timezone]", Intl.DateTimeFormat().resolvedOptions().timeZone)
252
+ errors.forEach((error) => body.append("errors[]", error))
253
+ if (draft.screenshot) body.append("screenshot", draft.screenshot, `screenshot.${draft.screenshot.type.split("/")[1]}`)
254
+
255
+ this.els.submit.disabled = true
256
+ this.showStatus(this.t("sending"))
257
+ try {
258
+ const response = await fetch(this.dataset.endpoint, {
259
+ method: "POST",
260
+ body,
261
+ credentials: "same-origin",
262
+ headers: { "X-CSRF-Token": document.querySelector("meta[name=csrf-token]")?.content || "", Accept: "application/json" }
263
+ })
264
+ if (response.ok) {
265
+ this.els.form.hidden = true
266
+ this.els.done.hidden = false
267
+ this.showStatus("")
268
+ } else if (response.status === 401 || response.status === 422 && !(await response.clone().json().catch(() => null))) {
269
+ this.showStatus(this.t("signed_out"), true)
270
+ } else if (response.status === 413) {
271
+ this.showStatus(this.t("image_too_large"), true)
272
+ } else {
273
+ this.showStatus(this.t("failed"), true)
274
+ }
275
+ } catch {
276
+ this.showStatus(this.t("failed"), true)
277
+ } finally {
278
+ this.els.submit.disabled = false
279
+ }
280
+ }
281
+
282
+ reset() {
283
+ draft.kind = "bug"
284
+ draft.description = ""
285
+ this.setScreenshot(null)
286
+ this.els.form.hidden = false
287
+ this.els.done.hidden = true
288
+ this.restore()
289
+ }
290
+
291
+ showStatus(text, error = false) {
292
+ this.els.status.textContent = text
293
+ this.els.status.classList.toggle("error", error)
294
+ }
295
+
296
+ escape(text) {
297
+ return String(text).replace(/[&<>"']/g, (char) => `&#${char.charCodeAt(0)};`)
298
+ }
299
+ }
300
+
301
+ if (!customElements.get("ticket-widget")) customElements.define("ticket-widget", TicketWidget)
@@ -0,0 +1,13 @@
1
+ module Ticket
2
+ # Inherits from the host's controller so its authentication, locale and browser rules apply.
3
+ class ApplicationController < Ticket.config.parent_controller.constantize
4
+ private
5
+ def current_reporter
6
+ @current_reporter ||= instance_exec(&Ticket.config.current_reporter)
7
+ end
8
+
9
+ def require_reporter
10
+ head :unauthorized unless current_reporter
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,51 @@
1
+ module Ticket
2
+ class ReportsController < ApplicationController
3
+ before_action :require_reporter
4
+
5
+ def create
6
+ report = Report.new(report_params)
7
+ report.reporter = current_reporter
8
+ report.screenshot_upload = params[:screenshot]
9
+ report.context = context
10
+
11
+ if report.save
12
+ report.deliver_later
13
+ render json: { id: report.id }, status: :created
14
+ else
15
+ render json: { errors: report.errors.full_messages }, status: :unprocessable_content
16
+ end
17
+ end
18
+
19
+ # The screenshot an issue links to; behind the host's sign-in like any other page.
20
+ def screenshot
21
+ report = Report.find(params[:id])
22
+ return head :not_found unless report.screenshot?
23
+
24
+ send_data report.screenshot, type: report.screenshot_content_type, disposition: :inline,
25
+ filename: "ticket-#{report.id}.#{Report::IMAGE_TYPES.fetch(report.screenshot_content_type)}"
26
+ end
27
+
28
+ private
29
+ def report_params
30
+ params.permit(:kind, :description, :page_url, :page_title)
31
+ end
32
+
33
+ def context
34
+ browser = params.fetch(:browser, {}).permit(:viewport, :screen, :language, :timezone).to_h
35
+ {
36
+ "user_agent" => request.user_agent,
37
+ "request_id" => params[:page_request_id].presence,
38
+ "app_version" => app_version,
39
+ "environment" => Rails.env,
40
+ "engine_url" => request.base_url + request.script_name,
41
+ "browser" => browser,
42
+ "errors" => Array(params[:errors]).first(10).map { |error| error.to_s.first(500) }
43
+ }.compact_blank
44
+ end
45
+
46
+ def app_version
47
+ version = Ticket.config.app_version
48
+ version.respond_to?(:call) ? version.call : version
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,18 @@
1
+ module Ticket
2
+ # `<%= ticket_widget %>` in the host's layout: the button, for signed-in people only.
3
+ module WidgetHelper
4
+ def ticket_widget
5
+ return unless respond_to?(:ticket) && instance_exec(&Ticket.config.current_reporter)
6
+
7
+ safe_join [
8
+ tag.ticket_widget(data: { endpoint: ticket.reports_path, request_id: request&.request_id, strings: t("ticket.widget").to_json }),
9
+ javascript_include_tag("ticket/widget", type: "module", nonce: true)
10
+ ]
11
+ rescue StandardError => error
12
+ # The host's page matters more than the feedback button.
13
+ raise unless Rails.env.production?
14
+ Rails.logger.error("[ticket] widget not rendered: #{error.class}: #{error.message}")
15
+ nil
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,23 @@
1
+ module Ticket
2
+ # Not the host's ApplicationJob: none of its callbacks or retries apply here.
3
+ class DeliverJob < ActiveJob::Base
4
+ queue_as :default
5
+
6
+ retry_on GitHub::Error, wait: :polynomially_longer, attempts: 10 do |job, error|
7
+ job.arguments.first.update!(state: :failed, error: error.message)
8
+ end
9
+
10
+ discard_on GitHub::Rejected do |job, error|
11
+ job.arguments.first.update!(state: :failed, error: error.message)
12
+ end
13
+
14
+ discard_on ActiveJob::DeserializationError
15
+
16
+ def perform(report)
17
+ return if report.sent?
18
+
19
+ issue = Ticket.client.create_issue(title: report.title, body: IssueBody.new(report).to_s, labels: report.labels)
20
+ report.update!(state: :sent, issue_number: issue[:number], issue_url: issue[:url], error: nil)
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,5 @@
1
+ module Ticket
2
+ class ApplicationRecord < ActiveRecord::Base
3
+ self.abstract_class = true
4
+ end
5
+ end
@@ -0,0 +1,54 @@
1
+ module Ticket
2
+ # The Markdown of the issue: the report in the reporter's words, then what was collected around it.
3
+ class IssueBody
4
+ def initialize(report)
5
+ @report = report
6
+ @context = report.context || {}
7
+ end
8
+
9
+ def to_s
10
+ [ description, screenshot, reporter, details ].compact.join("\n\n")
11
+ end
12
+
13
+ private
14
+ def description
15
+ @report.description.strip
16
+ end
17
+
18
+ def screenshot
19
+ "**Screenshot:** [view](#{@report.screenshot_url}) (sign in to the app to open it)" if @report.screenshot_url
20
+ end
21
+
22
+ def reporter
23
+ page = @report.page_url.present? ? "[#{escape(@report.page_title.presence || @report.page_url)}](<#{@report.page_url.delete("<>")}>)" : "unknown page"
24
+ "Reported by **#{escape(@report.reporter_name)}** on #{page}, #{@report.created_at.utc.strftime("%Y-%m-%d %H:%M UTC")}."
25
+ end
26
+
27
+ def details
28
+ rows = {
29
+ "Report" => "##{@report.id}",
30
+ "Version" => @context["app_version"],
31
+ "Environment" => @context["environment"],
32
+ "Request" => @context["request_id"],
33
+ "Browser" => @context["user_agent"],
34
+ "Viewport" => @context.dig("browser", "viewport"),
35
+ "Screen" => @context.dig("browser", "screen"),
36
+ "Language" => @context.dig("browser", "language"),
37
+ "Time zone" => @context.dig("browser", "timezone")
38
+ }.compact_blank
39
+
40
+ table = rows.map { |name, value| "| #{name} | `#{value.to_s.delete("`|")}` |" }
41
+ lines = [ "<details><summary>Context</summary>", "", "| | |", "|---|---|", *table ]
42
+
43
+ if (errors = @context["errors"]).present?
44
+ lines += [ "", "JavaScript errors on the page:", "", "```", *errors, "```" ]
45
+ end
46
+
47
+ (lines + [ "", "</details>" ]).join("\n")
48
+ end
49
+
50
+ def escape(text)
51
+ text.to_s.gsub(/[\[\]<>*_`]/) { |char| "\\#{char}" }
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,58 @@
1
+ module Ticket
2
+ # A report kept in the host's database, so nothing is lost while GitHub is unreachable.
3
+ class Report < ApplicationRecord
4
+ KINDS = %w[ bug idea question ].freeze
5
+ IMAGE_TYPES = { "image/png" => "png", "image/jpeg" => "jpg", "image/webp" => "webp" }.freeze
6
+
7
+ enum :state, { pending: "pending", sent: "sent", failed: "failed" }, default: "pending"
8
+
9
+ serialize :context, coder: JSON
10
+
11
+ validates :kind, inclusion: { in: KINDS }
12
+ validates :description, presence: true, length: { maximum: 20_000 }
13
+ validates :screenshot_content_type, inclusion: { in: IMAGE_TYPES.keys }, allow_nil: true
14
+ validate :screenshot_within_limit
15
+
16
+ # What `ticket:redeliver` sends again.
17
+ scope :undelivered, -> { where.not(state: "sent") }
18
+
19
+ def reporter=(reporter)
20
+ self.reporter_id = reporter.try(:id)&.to_s
21
+ self.reporter_name = Ticket.config.reporter_name.call(reporter).to_s.first(200)
22
+ self.reporter_email = reporter.try(:email_address) || reporter.try(:email)
23
+ end
24
+
25
+ def screenshot_upload=(upload)
26
+ return unless upload.respond_to?(:read)
27
+
28
+ self.screenshot_content_type = upload.content_type
29
+ self.screenshot = upload.read
30
+ end
31
+
32
+ def screenshot?
33
+ screenshot.present?
34
+ end
35
+
36
+ def deliver_later
37
+ DeliverJob.perform_later(self) if Ticket.config.deliver?
38
+ end
39
+
40
+ def title
41
+ "[#{kind.capitalize}] #{description.squish.truncate(80)}"
42
+ end
43
+
44
+ # The engine's address as the reporter saw it, so the issue can link back to the screenshot.
45
+ def screenshot_url
46
+ "#{context["engine_url"]}/reports/#{id}/screenshot" if screenshot? && context&.dig("engine_url")
47
+ end
48
+
49
+ def labels
50
+ Ticket.config.labels + [ kind ]
51
+ end
52
+
53
+ private
54
+ def screenshot_within_limit
55
+ errors.add(:screenshot, :too_long, count: Ticket.config.max_screenshot_size) if screenshot.to_s.bytesize > Ticket.config.max_screenshot_size
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,24 @@
1
+ en:
2
+ ticket:
3
+ widget:
4
+ button: Feedback
5
+ title: Send feedback
6
+ kinds:
7
+ bug: Something is wrong
8
+ idea: An idea
9
+ question: A question
10
+ description: What happened, or what would help?
11
+ description_hint: The page you are on and your browser are sent with it.
12
+ capture: Capture the screen
13
+ choose: Choose an image
14
+ paste_hint: or paste or drop an image here
15
+ remove: Remove
16
+ capture_failed: The screen could not be captured.
17
+ image_too_large: This image is too large.
18
+ submit: Send
19
+ sending: Sending…
20
+ sent: Thank you, it has been sent to the team.
21
+ signed_out: Your session has ended. Sign in again, then send it.
22
+ failed: It could not be sent. Try again in a moment.
23
+ close: Close
24
+ another: Send another
@@ -0,0 +1,24 @@
1
+ fr:
2
+ ticket:
3
+ widget:
4
+ button: Signaler
5
+ title: Faire un retour
6
+ kinds:
7
+ bug: Quelque chose ne va pas
8
+ idea: Une idée
9
+ question: Une question
10
+ description: Que s’est-il passé, ou qu’est-ce qui aiderait ?
11
+ description_hint: La page où vous êtes et votre navigateur sont envoyés avec.
12
+ capture: Capturer l’écran
13
+ choose: Choisir une image
14
+ paste_hint: ou collez ou déposez une image ici
15
+ remove: Retirer
16
+ capture_failed: L’écran n’a pas pu être capturé.
17
+ image_too_large: Cette image est trop lourde.
18
+ submit: Envoyer
19
+ sending: Envoi…
20
+ sent: Merci, c’est transmis à l’équipe.
21
+ signed_out: Votre session a expiré. Reconnectez-vous, puis envoyez.
22
+ failed: L’envoi a échoué. Réessayez dans un instant.
23
+ close: Fermer
24
+ another: En envoyer un autre
data/config/routes.rb ADDED
@@ -0,0 +1,5 @@
1
+ Ticket::Engine.routes.draw do
2
+ resources :reports, only: :create do
3
+ get :screenshot, on: :member
4
+ end
5
+ end
@@ -0,0 +1,23 @@
1
+ class CreateTicketReports < ActiveRecord::Migration[8.0]
2
+ def change
3
+ create_table :ticket_reports do |t|
4
+ t.string :kind, null: false
5
+ t.text :description, null: false
6
+ t.string :page_url
7
+ t.string :page_title
8
+ t.string :reporter_id
9
+ t.string :reporter_name
10
+ t.string :reporter_email
11
+ t.text :context
12
+ t.binary :screenshot
13
+ t.string :screenshot_content_type
14
+ t.string :state, null: false, default: "pending"
15
+ t.integer :issue_number
16
+ t.string :issue_url
17
+ t.text :error
18
+ t.timestamps
19
+ end
20
+
21
+ add_index :ticket_reports, :state
22
+ end
23
+ end
@@ -0,0 +1,12 @@
1
+ namespace :ticket do
2
+ desc "Send again the reports GitHub has not received"
3
+ task redeliver: :environment do
4
+ abort "Ticket has no repository or token to deliver to" unless Ticket.config.deliver?
5
+
6
+ Ticket::Report.undelivered.find_each do |report|
7
+ report.update!(state: :pending, error: nil)
8
+ Ticket::DeliverJob.perform_later(report)
9
+ puts "Report ##{report.id} queued"
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,40 @@
1
+ module Ticket
2
+ class Configuration
3
+ # "owner/name" of the repository the issues go to.
4
+ attr_accessor :repository
5
+
6
+ # A token allowed to create issues in that repository. Without one, reports are kept but not sent.
7
+ attr_accessor :github_token
8
+
9
+ # Labels added to every issue, before the one for the kind of report.
10
+ attr_accessor :labels
11
+
12
+ # Called in the controller and the view; returns the person reporting, or nil to hide the widget.
13
+ attr_accessor :current_reporter
14
+
15
+ # How a reporter is named in the issue. Gets the object current_reporter returned.
16
+ attr_accessor :reporter_name
17
+
18
+ # The host controller the engine's controller inherits from, for its authentication.
19
+ attr_accessor :parent_controller
20
+
21
+ # The deployed version, shown in the issue. A string or a lambda.
22
+ attr_accessor :app_version
23
+
24
+ # The largest screenshot accepted, in bytes.
25
+ attr_accessor :max_screenshot_size
26
+
27
+ def initialize
28
+ @labels = [ "feedback" ]
29
+ @current_reporter = -> { nil }
30
+ @reporter_name = ->(reporter) { reporter.try(:name).presence || reporter.try(:email_address) || reporter.to_s }
31
+ @parent_controller = "::ApplicationController"
32
+ @app_version = -> { ENV["KAMAL_VERSION"] || ENV["GIT_REVISION"] }
33
+ @max_screenshot_size = 8.megabytes
34
+ end
35
+
36
+ def deliver?
37
+ repository.present? && github_token.present?
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,10 @@
1
+ module Ticket
2
+ class Engine < ::Rails::Engine
3
+ isolate_namespace Ticket
4
+
5
+ # The widget helper is available in every host view, without touching the host's helpers.
6
+ initializer "ticket.helpers" do
7
+ ActiveSupport.on_load(:action_view) { include Ticket::WidgetHelper }
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,61 @@
1
+ require "net/http"
2
+ require "json"
3
+
4
+ module Ticket
5
+ # The one call made to GitHub: create an issue. Net::HTTP, so the host takes no new dependency.
6
+ class GitHub
7
+ class Error < StandardError; end
8
+
9
+ # A refusal that will not change on retry (bad token, missing repository, invalid input).
10
+ class Rejected < Error; end
11
+
12
+ API = URI("https://api.github.com")
13
+
14
+ # GitHub out of reach: worth trying again, like an answer in the 500s.
15
+ NETWORK_ERRORS = [ Net::OpenTimeout, Net::ReadTimeout, SocketError, SystemCallError, OpenSSL::SSL::SSLError, EOFError, IOError ].freeze
16
+
17
+ def initialize(token:, repository:)
18
+ @token = token
19
+ @repository = repository
20
+ end
21
+
22
+ # Returns { number:, url: }.
23
+ def create_issue(title:, body:, labels: [])
24
+ response = post("/repos/#{@repository}/issues", title: title, body: body, labels: labels)
25
+
26
+ # Labels are dropped rather than lose the report when the token may not set them.
27
+ if response.code == "422" && labels.any? && response.body.to_s.include?("label")
28
+ response = post("/repos/#{@repository}/issues", title: title, body: body)
29
+ end
30
+
31
+ case response
32
+ when Net::HTTPSuccess
33
+ issue = JSON.parse(response.body)
34
+ { number: issue["number"], url: issue["html_url"] }
35
+ when Net::HTTPTooManyRequests, Net::HTTPServerError
36
+ raise Error, "GitHub answered #{response.code}: #{response.body.to_s.first(300)}"
37
+ else
38
+ raise Rejected, "GitHub answered #{response.code}: #{response.body.to_s.first(300)}"
39
+ end
40
+ end
41
+
42
+ private
43
+ def post(path, payload)
44
+ request = Net::HTTP::Post.new(path)
45
+ request["Authorization"] = "Bearer #{@token}"
46
+ request["Accept"] = "application/vnd.github+json"
47
+ request["X-GitHub-Api-Version"] = "2022-11-28"
48
+ request["Content-Type"] = "application/json"
49
+ request.body = JSON.generate(payload)
50
+ perform(request)
51
+ end
52
+
53
+ def perform(request)
54
+ Net::HTTP.start(API.host, API.port, use_ssl: true, open_timeout: 10, read_timeout: 20) do |http|
55
+ http.request(request)
56
+ end
57
+ rescue *NETWORK_ERRORS => error
58
+ raise Error, "GitHub could not be reached: #{error.class}: #{error.message}"
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,3 @@
1
+ module Ticket
2
+ VERSION = "0.1.0"
3
+ end
data/lib/ticket.rb ADDED
@@ -0,0 +1,24 @@
1
+ require "ticket/version"
2
+ require "ticket/configuration"
3
+ require "ticket/github"
4
+ require "ticket/engine"
5
+
6
+ # Feedback from the people using an app, filed as GitHub issues in that app's repository.
7
+ module Ticket
8
+ class << self
9
+ def config
10
+ @config ||= Configuration.new
11
+ end
12
+
13
+ def configure
14
+ yield config
15
+ end
16
+
17
+ # Replaced in tests; anything that answers #create_issue(title:, body:, labels:).
18
+ attr_writer :client
19
+
20
+ def client
21
+ @client ||= GitHub.new(token: config.github_token, repository: config.repository)
22
+ end
23
+ end
24
+ end
metadata ADDED
@@ -0,0 +1,77 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: ticket
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - B.O.X
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: rails
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '8.0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '8.0'
26
+ description: Lets the people using an app report a bug, an idea or a question, with
27
+ a screenshot, without a GitHub account. Each report becomes an issue in the app's
28
+ repository.
29
+ executables: []
30
+ extensions: []
31
+ extra_rdoc_files: []
32
+ files:
33
+ - MIT-LICENSE
34
+ - README.md
35
+ - Rakefile
36
+ - app/assets/javascripts/ticket/widget.js
37
+ - app/controllers/ticket/application_controller.rb
38
+ - app/controllers/ticket/reports_controller.rb
39
+ - app/helpers/ticket/widget_helper.rb
40
+ - app/jobs/ticket/deliver_job.rb
41
+ - app/models/ticket/application_record.rb
42
+ - app/models/ticket/issue_body.rb
43
+ - app/models/ticket/report.rb
44
+ - config/locales/ticket.en.yml
45
+ - config/locales/ticket.fr.yml
46
+ - config/routes.rb
47
+ - db/migrate/20261006000000_create_ticket_reports.rb
48
+ - lib/tasks/ticket_tasks.rake
49
+ - lib/ticket.rb
50
+ - lib/ticket/configuration.rb
51
+ - lib/ticket/engine.rb
52
+ - lib/ticket/github.rb
53
+ - lib/ticket/version.rb
54
+ homepage: https://github.com/boxprod/ticket
55
+ licenses:
56
+ - MIT
57
+ metadata:
58
+ rubygems_mfa_required: 'true'
59
+ source_code_uri: https://github.com/boxprod/ticket
60
+ rdoc_options: []
61
+ require_paths:
62
+ - lib
63
+ required_ruby_version: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: '3.3'
68
+ required_rubygems_version: !ruby/object:Gem::Requirement
69
+ requirements:
70
+ - - ">="
71
+ - !ruby/object:Gem::Version
72
+ version: '0'
73
+ requirements: []
74
+ rubygems_version: 4.0.20
75
+ specification_version: 4
76
+ summary: A feedback button for Rails apps that files GitHub issues.
77
+ test_files: []