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 +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +76 -0
- data/Rakefile +6 -0
- data/app/assets/javascripts/ticket/widget.js +301 -0
- data/app/controllers/ticket/application_controller.rb +13 -0
- data/app/controllers/ticket/reports_controller.rb +51 -0
- data/app/helpers/ticket/widget_helper.rb +18 -0
- data/app/jobs/ticket/deliver_job.rb +23 -0
- data/app/models/ticket/application_record.rb +5 -0
- data/app/models/ticket/issue_body.rb +54 -0
- data/app/models/ticket/report.rb +58 -0
- data/config/locales/ticket.en.yml +24 -0
- data/config/locales/ticket.fr.yml +24 -0
- data/config/routes.rb +5 -0
- data/db/migrate/20261006000000_create_ticket_reports.rb +23 -0
- data/lib/tasks/ticket_tasks.rake +12 -0
- data/lib/ticket/configuration.rb +40 -0
- data/lib/ticket/engine.rb +10 -0
- data/lib/ticket/github.rb +61 -0
- data/lib/ticket/version.rb +3 -0
- data/lib/ticket.rb +24 -0
- metadata +77 -0
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,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,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,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
|
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: []
|