@supersuit/suggest-a-change 0.1.12 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/CHANGELOG.md +22 -1
  2. package/README.md +163 -8
  3. package/dist/client/adapters.d.ts +27 -0
  4. package/dist/client/context.d.ts +61 -0
  5. package/dist/client/context.js +279 -0
  6. package/dist/client/detail.d.ts +2 -2
  7. package/dist/client/detail.js +12 -8
  8. package/dist/client/entry.js +10 -5
  9. package/dist/client/index.d.ts +2 -0
  10. package/dist/client/index.js +1 -0
  11. package/dist/client/mine.js +4 -2
  12. package/dist/client/runtime.js +3 -0
  13. package/dist/client/seen.d.ts +12 -0
  14. package/dist/client/seen.js +30 -7
  15. package/dist/client/sheet.js +64 -4
  16. package/dist/client/words.d.ts +10 -1
  17. package/dist/client/words.js +11 -2
  18. package/dist/contract.d.mts +32 -22
  19. package/dist/contract.mjs +127 -37
  20. package/dist/github/api.d.ts +100 -0
  21. package/dist/github/api.js +164 -0
  22. package/dist/github/compose.d.ts +152 -0
  23. package/dist/github/compose.js +309 -0
  24. package/dist/github/filer.d.ts +72 -0
  25. package/dist/github/filer.js +89 -0
  26. package/dist/github/hosts.d.ts +41 -0
  27. package/dist/github/hosts.js +154 -0
  28. package/dist/github/index.d.ts +12 -0
  29. package/dist/github/index.js +9 -0
  30. package/dist/github/privacy.d.ts +34 -0
  31. package/dist/github/privacy.js +142 -0
  32. package/dist/github/relay.d.ts +159 -0
  33. package/dist/github/relay.js +734 -0
  34. package/dist/server/geo.d.ts +17 -0
  35. package/dist/server/geo.js +35 -0
  36. package/dist/server/handlers.d.ts +54 -3
  37. package/dist/server/handlers.js +143 -41
  38. package/dist/server/index.d.ts +3 -1
  39. package/dist/server/index.js +1 -0
  40. package/package.json +5 -1
  41. package/src/client/adapters.ts +18 -1
  42. package/src/client/context.ts +243 -0
  43. package/src/client/detail.tsx +13 -8
  44. package/src/client/entry.tsx +9 -5
  45. package/src/client/index.ts +2 -0
  46. package/src/client/mine.tsx +7 -3
  47. package/src/client/runtime.ts +3 -0
  48. package/src/client/seen.ts +27 -6
  49. package/src/client/sheet.tsx +78 -4
  50. package/src/client/styles.css +11 -2
  51. package/src/client/words.ts +15 -2
  52. package/src/contract.mjs +110 -37
  53. package/src/github/api.ts +169 -0
  54. package/src/github/compose.ts +354 -0
  55. package/src/github/filer.ts +96 -0
  56. package/src/github/hosts.ts +117 -0
  57. package/src/github/index.ts +15 -0
  58. package/src/github/privacy.ts +131 -0
  59. package/src/github/relay.ts +637 -0
  60. package/src/server/geo.ts +32 -0
  61. package/src/server/handlers.ts +158 -45
  62. package/src/server/index.ts +3 -1
@@ -0,0 +1,354 @@
1
+ // ONE PLAIN ISSUE, AND THE LIST OF WHAT GOES INTO IT (suggestions-as-github-issues design, sections 1 and 2).
2
+ // `pieces` decides what a report carries; `whatGoes` says it in the sheet's words and `composeIssue` writes it into
3
+ // the body, both from that one decision, so the sheet can never promise less than the issue carries. No model is
4
+ // involved: the body is the person's own words and the facts the app stamped, laid out the same way every time.
5
+
6
+ /** A piece the report carries. `ctx:<key>` is one context item; routes, taps and errors are the trail; contact is how to reach them. */
7
+ export type Piece = 'words' | 'voice' | 'images' | 'video' | 'where' | 'app' | 'device' | 'time' | 'routes' | 'taps' | 'errors' | 'contact' | 'filer' | `ctx:${string}`
8
+ export interface ContextItem { key: string; label: string; value: string; why: string }
9
+ export interface DraftContext {
10
+ items?: ContextItem[]
11
+ routes?: { route: string; at: string }[]
12
+ taps?: { label: string; at: string }[]
13
+ errors?: { message: string; frame?: string; at: string }[]
14
+ }
15
+ export interface Contact { email?: string; phone?: string; handle?: string }
16
+ /** One line of the sheet's list. `optional` rows can be left out; `on` is whether they go by default, `reason` why not. */
17
+ export interface GoesItem { key: Piece; text: string; value?: string; why?: string; optional?: boolean; on?: boolean; reason?: string }
18
+ export interface Destination {
19
+ /** owner/name of the repo the issue files on. */
20
+ repo: string
21
+ /** True for a public repo: the sheet says so in the danger colour. */
22
+ public: boolean
23
+ /** The GitHub login the issue is filed from. */
24
+ account: string
25
+ /** False when files cannot reach this repo yet (no App installed and the filer cannot push): they wait. */
26
+ attaches: boolean
27
+ /** False when nobody transcribes voice notes (an app's own server): the list does not promise a transcript. */
28
+ transcribes?: boolean
29
+ /** The largest file this destination takes; a larger one is named in the list as staying on the phone. */
30
+ maxInlineBytes?: number
31
+ }
32
+ /** What composeIssue and whatGoes read: a report in the contract's shape, plus two optional names for the list. */
33
+ export interface Draft {
34
+ id?: string
35
+ kind?: string
36
+ words?: string
37
+ who?: { member?: string; role?: string; contact?: Contact }
38
+ /** Everything else shared so it can be fixed (the module's `collectContext`, and the app's own items). */
39
+ context?: DraftContext
40
+ voice?: { file: string; ms?: number }[]
41
+ media?: { file: string; type: string; bytes?: number; ms?: number }[]
42
+ where?: { screen?: string; route?: string }
43
+ diagnostics?: { appVersion?: string; at?: string; device?: string; screen?: string; [k: string]: unknown }
44
+ /** The sender's name as the issue will say it (the list's "your name, ..."). */
45
+ filerName?: string
46
+ /** The app's name, for the list's "Helm 2.14.0". */
47
+ appName?: string
48
+ [k: string]: unknown
49
+ }
50
+ export interface ComposeOptions {
51
+ appName?: string
52
+ /** The app label's host-given name (`app:<appLabel>`); null for none. Without it the label is made from appName. */
53
+ appLabel?: string | null
54
+ appVersion?: string
55
+ filerName?: string
56
+ filerRole?: string
57
+ /** file name -> its link, from an AttachmentHost. A file with no link yet is written as still being added. */
58
+ attachmentUrls?: Record<string, string>
59
+ /** The voice notes' transcript, made on the owner's Mac. */
60
+ transcript?: string
61
+ /** The first body, written before any file is saved: every attachment is "being added". */
62
+ pending?: boolean
63
+ }
64
+ export interface ComposedIssue { title: string; body: string; labels: string[]; carries: Piece[] }
65
+
66
+ /** GitHub refuses a body of 65536 characters or more; this leaves room. */
67
+ export const BODY_LIMIT = 65_000
68
+ const LABEL: Record<string, string> = { change: 'enhancement', broken: 'bug', feedback: 'feedback' }
69
+ const EMAIL = /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g
70
+
71
+ const isImage = (m: { type: string }) => String(m.type).startsWith('image/')
72
+ const isVideo = (m: { type: string }) => String(m.type).startsWith('video/')
73
+ const text = (v: unknown) => (typeof v === 'string' ? v : '')
74
+ const plural = (n: number, one: string, many = `${one}s`) => `${n} ${n === 1 ? one : many}`
75
+ const list = (parts: string[]) => (parts.length <= 1 ? parts.join('') : `${parts.slice(0, -1).join(', ')} and ${parts[parts.length - 1]}`)
76
+
77
+ const contactOf = (d: Draft): Contact | null => {
78
+ const c = d.who?.contact
79
+ return c && (c.email || c.phone || c.handle) ? c : null
80
+ }
81
+ /** Whether a report carries context; with it, the context table stands in for the About line. */
82
+ const hasContext = (d: Draft) => Boolean(d.context && ((d.context.items ?? []).length || (d.context.routes ?? []).length || (d.context.taps ?? []).length || (d.context.errors ?? []).length))
83
+
84
+ /** Which pieces a report carries, in the order the issue and the list both use. */
85
+ export function pieces(d: Draft): Piece[] {
86
+ const media = d.media ?? []
87
+ const g = d.diagnostics ?? {}
88
+ const out: Piece[] = []
89
+ if (text(d.words).trim()) out.push('words')
90
+ if ((d.voice ?? []).length) out.push('voice')
91
+ if (media.some(isImage)) out.push('images')
92
+ if (media.some(isVideo)) out.push('video')
93
+ if (text(d.where?.screen) || text(d.where?.route)) out.push('where')
94
+ if (text(g.appVersion)) out.push('app')
95
+ if (hasContext(d)) {
96
+ for (const i of d.context!.items ?? []) out.push(`ctx:${i.key}`)
97
+ if ((d.context!.routes ?? []).length) out.push('routes')
98
+ if ((d.context!.taps ?? []).length) out.push('taps')
99
+ if ((d.context!.errors ?? []).length) out.push('errors')
100
+ } else {
101
+ if (text(g.device) || text(g.screen)) out.push('device')
102
+ if (text(g.at)) out.push('time')
103
+ }
104
+ if (contactOf(d)) out.push('contact')
105
+ out.push('filer')
106
+ return out
107
+ }
108
+
109
+ export const TRAIL_WORDS = {
110
+ routes: { label: 'Pages you visited before this', heading: 'Pages visited before this', why: 'so the fixer can retrace how you got here' },
111
+ taps: { label: 'Your last taps', heading: 'Last taps', why: 'so the fixer can repeat what you did, by what the buttons are called; never what you typed' },
112
+ errors: { label: 'Errors the page hit', heading: 'Errors on the page', why: 'the error the app hit is often the fastest way to the cause' },
113
+ }
114
+ export const CONTACT_WHY = 'so whoever fixes it can thank you or ask a follow-up question'
115
+ export const PUBLIC_CONTACT_REASON = 'this repo is public, so anyone could read it'
116
+ const contactValue = (c: Contact) => [c.email, c.phone, c.handle].filter(Boolean).join(' · ')
117
+
118
+ /** The sheet's list, in plain words, one item per piece the issue will carry, with the value and why for each piece
119
+ * of context. Anything but what the person added, the app's version and their name can be left out (`optional`). */
120
+ export function whatGoes(draft: Draft, destination: Destination): GoesItem[] {
121
+ const ctxItems = new Map((draft.context?.items ?? []).map((i) => [`ctx:${i.key}`, i]))
122
+ const contact = contactOf(draft)
123
+ const trailCount = (k: 'routes' | 'taps' | 'errors') => (draft.context?.[k] ?? []).length
124
+ const trailUnit = { routes: 'page', taps: 'tap', errors: 'error' } as const
125
+ return basicGoes(draft, destination).map((g) => {
126
+ const c = ctxItems.get(g.key)
127
+ if (c) return { key: g.key, text: c.label, value: c.value, why: c.why, optional: true, on: true }
128
+ if (g.key === 'routes' || g.key === 'taps' || g.key === 'errors') {
129
+ const w = TRAIL_WORDS[g.key]
130
+ return { key: g.key, text: w.label, value: plural(trailCount(g.key), trailUnit[g.key]), why: w.why, optional: true, on: true }
131
+ }
132
+ if (g.key === 'contact' && contact) {
133
+ return { key: g.key, text: 'How to reach you', value: contactValue(contact), why: CONTACT_WHY, optional: true, on: !destination.public, ...(destination.public ? { reason: PUBLIC_CONTACT_REASON } : {}) }
134
+ }
135
+ return g
136
+ })
137
+ }
138
+
139
+ function basicGoes(draft: Draft, destination: Destination): GoesItem[] {
140
+ const media = draft.media ?? []
141
+ const g = draft.diagnostics ?? {}
142
+ const kept = destination.attaches
143
+ ? `saved to the suggestion-attachments branch of ${destination.repo}`
144
+ : `added once ${destination.repo} can take attachments`
145
+ const voices = (draft.voice ?? []).length
146
+ // A file the destination cannot take is said so in advance, by name, before anything is sent.
147
+ const max = destination.maxInlineBytes
148
+ const mb = (n: number) => (n < 2 ** 20 ? `${Math.max(1, Math.round(n / 1024))} KB` : `${Math.round((n / 2 ** 20) * 10) / 10} MB`)
149
+ const tooBig = (files: { file: string; bytes?: number }[]) => {
150
+ const over = max ? files.filter((m) => (m.bytes ?? 0) > max) : []
151
+ return over.length ? `; ${list(over.map((m) => m.file))} ${over.length === 1 ? 'is' : 'are'} over the ${mb(max!)} ${destination.repo} can take, so ${over.length === 1 ? 'it does' : 'they do'} not reach the issue` : ''
152
+ }
153
+ const say: Record<string, () => string> = {
154
+ words: () => 'your words',
155
+ voice: () => destination.transcribes === false
156
+ ? (voices === 1 ? 'your voice note' : `your ${voices} voice notes`)
157
+ : (voices === 1 ? 'your voice note and its transcript' : `your ${voices} voice notes and their transcripts`),
158
+ images: () => `${plural(media.filter(isImage).length, 'screenshot')}, ${kept}${tooBig(media.filter(isImage))}`,
159
+ video: () => `${plural(media.filter(isVideo).length, 'video')}, ${kept}${tooBig(media.filter(isVideo))}`,
160
+ where: () => `this screen (${[text(draft.where?.screen), text(draft.where?.route)].filter(Boolean).join(', ')})`,
161
+ app: () => `${draft.appName || 'the app'} ${text(g.appVersion)}`,
162
+ device: () => [text(g.device), text(g.screen)].filter(Boolean).join(', '),
163
+ time: () => 'the time',
164
+ filer: () => (draft.filerName ? `your name, ${draft.filerName}` : 'that it came from you'),
165
+ }
166
+ return pieces(draft).map((key) => ({ key, text: say[key]?.() ?? key }))
167
+ }
168
+
169
+ /** The lines around the list: where it files, who can read it there, and whose account files it. */
170
+ export function destinationWords(d: Destination): { headline: string; privacy: string; danger: boolean; account: string } {
171
+ return {
172
+ headline: `Sending this files an issue on ${d.repo}`,
173
+ privacy: d.public ? 'public: anyone on the internet can read this' : `private: people with access to ${d.repo}'s code can read it`,
174
+ danger: Boolean(d.public),
175
+ account: `Filed from ${d.account}'s GitHub account.`,
176
+ }
177
+ }
178
+
179
+ /** "2026-10-26T17:04:12Z" -> "2026-10-26 17:04 UTC"; anything unreadable as it is. */
180
+ function when(at: string): string {
181
+ const t = Date.parse(at)
182
+ if (!Number.isFinite(t)) return inline(at) // not a time: shown, escaped, as any other line
183
+ const iso = new Date(t).toISOString()
184
+ return `${iso.slice(0, 10)} ${iso.slice(11, 16)} UTC`
185
+ }
186
+ function firstSentence(s: string): string {
187
+ const line = s.trim().split('\n')[0].trim()
188
+ const m = /^.*?[.!?](?=\s|$)/.exec(line)
189
+ return (m ? m[0] : line).trim()
190
+ }
191
+ function clip(s: string, max: number): string {
192
+ if (s.length <= max) return s
193
+ let cut = s.slice(0, max - 1)
194
+ const space = cut.lastIndexOf(' ')
195
+ if (space > max / 2) cut = cut.slice(0, space)
196
+ return `${cut.trimEnd()}…`
197
+ }
198
+ /** Text that cannot become markup in the issue. */
199
+ const html = (s: string) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
200
+ /** A table cell: no markup, no pipe that ends the cell, on one line. */
201
+ const cell = (s: string) => inline(s).replace(/\|/g, '\\|')
202
+ /** One line of report-borne text, made inert: no HTML, no emphasis, code, link or image syntax, no @mention, no #123
203
+ * reference, on one line. Every string a report brings that is not inside a fence goes through this. */
204
+ const inline = (s: string) => html(String(s)).replace(/[\\`*_[\]~!]/g, '\\$&').replace(/@(?=[\w-])/g, '@\u200d').replace(/#(?=\d)/g, '#\u200d').replace(/\r?\n/g, ' ')
205
+ /** Text a person wrote, shown as they wrote it inside a fence longer than any backtick run in it, so nothing in it renders. */
206
+ const fence = (s: string) => {
207
+ const run = Math.max(0, ...[...s.matchAll(/`+/g)].map((m) => m[0].length))
208
+ const f = '`'.repeat(Math.max(3, run + 1))
209
+ return `${f}\n${s}\n${f}`
210
+ }
211
+ /** Link text with no markdown of its own (an email address keeps its @: it is not a mention inside link text). */
212
+ const linkText = (s: string) => html(String(s)).replace(/[\\`*_[\]~!]/g, '\\$&').replace(/\r?\n/g, ' ')
213
+ /** Only an https link with nothing that could end or break the link may become one; anything else is not linked. */
214
+ const safeUrl = (u: unknown): string | null => (typeof u === 'string' && /^https:\/\/[^\s()<>"'`]+$/.test(u) ? u : null)
215
+ /** THE LAST LINE OF EVERY ISSUE, written and recognized by these two and nothing else: a relay accepts an attachment for
216
+ * a report only on an issue that ends with this line for it, so the writer and the reader must never drift apart. */
217
+ export const FILED_BY = '## Filed by'
218
+ export function filedByLine(filer: string, role: string, appName: string | undefined, reportId: string): string {
219
+ return `${inline(filer)} (${inline(role)}), through Suggest a change in ${appName ? `the ${inline(appName)}` : 'an app'}. Report ${inline(reportId)}.`
220
+ }
221
+ /** Whether an issue's body ends with the composer's own Filed by line for this report, in either of its forms. */
222
+ export function endsWithFiledBy(body: string, reportId: string): boolean {
223
+ if (!/^r-\d{14}-[a-z0-9]{6}$/.test(reportId)) return false
224
+ const lines = String(body).replace(/\s+$/, '').split('\n')
225
+ const last = lines[lines.length - 1] ?? ''
226
+ return lines[lines.length - 2]?.trim() === FILED_BY && new RegExp(`^\\S.* \\(.+\\), through Suggest a change in (the .+|an app)\\. Report ${reportId}\\.$`).test(last)
227
+ }
228
+ /** "2026-10-26T17:04:05Z" -> "17:04:05 UTC"; anything else as it is. */
229
+ const clock = (at: string) => { const t = Date.parse(at); return Number.isFinite(t) ? `${new Date(t).toISOString().slice(11, 19)} UTC` : inline(String(at)) }
230
+ const slug = (s: string) => s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '')
231
+
232
+ export function composeIssue(report: Draft, opts: ComposeOptions = {}): ComposedIssue {
233
+ // Never the sender's account key or an email, whatever the words say: the privacy catch should have held them, and this is the floor.
234
+ const key = text(report.who?.member)
235
+ const scrub = (s: string) => {
236
+ let out = s.replace(EMAIL, '[removed]')
237
+ if (key.length >= 12) out = out.split(key).join('[removed]')
238
+ return out
239
+ }
240
+ const words = scrub(text(report.words).trim())
241
+ const transcript = scrub(text(opts.transcript).trim())
242
+ // Only links the host produced, and only plain https ones, become images or links; anything else is still "being added".
243
+ const urls: Record<string, string> = opts.pending ? {} : Object.fromEntries(Object.entries(opts.attachmentUrls ?? {}).flatMap(([k, u]) => (safeUrl(u) ? [[k, u]] : [])))
244
+ const media = report.media ?? []
245
+ const voice = report.voice ?? []
246
+ const g = report.diagnostics ?? {}
247
+ const appName = opts.appName ?? report.appName
248
+ const has = new Set(pieces(report))
249
+ const carries = pieces(report)
250
+
251
+ // Everything not yet linked, counted, so the issue never claims to be complete before it is.
252
+ const missing = [...media, ...voice].filter((f) => !urls[f.file])
253
+ const waitImages = media.filter((m) => isImage(m) && !urls[m.file]).length
254
+ const waitVideos = media.filter((m) => isVideo(m) && !urls[m.file]).length
255
+ const waitVoice = voice.filter((v) => !urls[v.file]).length
256
+ const coming = missing.length
257
+ ? `${list([waitImages ? plural(waitImages, 'screenshot') : '', waitVideos ? plural(waitVideos, 'video') : '', waitVoice ? plural(waitVoice, 'voice recording') : ''].filter(Boolean))} ${missing.length === 1 ? 'is' : 'are'} being added.`
258
+ : ''
259
+
260
+ type TrailCut = { routes: number; taps: number; errors: number }
261
+ const full = { routes: report.context?.routes ?? [], taps: report.context?.taps ?? [], errors: report.context?.errors ?? [] }
262
+ const contact = contactOf(report)
263
+ // The trail is kept newest-last; a cut keeps the newest.
264
+ const shown = (k: keyof TrailCut, cut: TrailCut) => full[k].slice(cut[k])
265
+ const build = (said: string, heard: string, cut: TrailCut = { routes: 0, taps: 0, errors: 0 }): string => {
266
+ const out: string[] = []
267
+ if (has.has('words')) out.push('## What they said', fence(said), '')
268
+ if (has.has('voice')) {
269
+ out.push(transcript ? "## Voice note (transcribed on the owner's Mac)" : '## Voice note', heard ? fence(heard) : 'Not transcribed.')
270
+ const links = voice.filter((v) => urls[v.file]).map((v) => `[${inline(v.file)}](${urls[v.file]})`)
271
+ if (links.length) out.push('', ...links)
272
+ if (coming && !has.has('images') && !has.has('video')) out.push('', coming)
273
+ out.push('')
274
+ }
275
+ if (has.has('images') || has.has('video')) {
276
+ out.push('## Screenshots')
277
+ for (const m of media) {
278
+ const u = urls[m.file]
279
+ if (!u) continue
280
+ out.push(isImage(m) ? `![${inline(m.file)}](${u})` : `[${inline(m.file)}](${u})`)
281
+ }
282
+ if (coming) out.push(coming)
283
+ out.push('')
284
+ }
285
+ if (has.has('where')) {
286
+ out.push('## Where', [text(report.where?.screen) && `Screen: ${inline(text(report.where?.screen))}`, text(report.where?.route) && `Route: ${inline(text(report.where?.route))}`].filter(Boolean).join(' · '), '')
287
+ }
288
+ if (hasContext(report)) {
289
+ out.push('## Context (shared so this can be fixed)', '', '| What | Value | Why |', '| --- | --- | --- |')
290
+ if (has.has('app')) out.push(`| App | ${cell(`${appName ? `${appName} ` : ''}${text(g.appVersion)}`)} | so the fixer works on the same version you have |`)
291
+ for (const i of report.context!.items ?? []) out.push(`| ${cell(i.label)} | ${cell(i.value)} | ${cell(i.why)} |`)
292
+ out.push('')
293
+ const block = (k: keyof TrailCut, line: (x: never) => string) => {
294
+ if (!has.has(k)) return
295
+ const rows = shown(k, cut)
296
+ out.push(`<details><summary>${TRAIL_WORDS[k].heading} (${full[k].length})</summary>`, '')
297
+ if (cut[k]) out.push(`(The ${cut[k]} oldest were cut to fit GitHub's limit.)`, '')
298
+ out.push(...rows.map((x) => line(x as never)), '', '</details>', '')
299
+ }
300
+ block('routes', (r: { route: string; at: string }) => `- ${clock(r.at)} ${inline(r.route)}`)
301
+ block('taps', (t: { label: string; at: string }) => `- ${clock(t.at)} ${inline(t.label)}`)
302
+ block('errors', (e: { message: string; frame?: string; at: string }) => `- ${clock(e.at)} ${inline(e.message)}${e.frame ? `\n ${inline(e.frame)}` : ''}`)
303
+ } else if (has.has('app') || has.has('device') || has.has('time')) {
304
+ const about = [
305
+ has.has('app') ? `App: ${inline(`${appName ? `${appName} ` : ''}${text(g.appVersion)}`)}` : '',
306
+ text(g.device) ? `Device: ${inline(text(g.device))}` : '',
307
+ text(g.screen) ? `Screen ${inline(text(g.screen))}` : '',
308
+ has.has('time') ? `Sent ${when(text(g.at))}` : '',
309
+ ].filter(Boolean)
310
+ out.push('## About', about.join(' · '), '')
311
+ }
312
+ if (has.has('contact') && contact) {
313
+ const ways = [
314
+ contact.email ? `[${linkText(contact.email)}](mailto:${encodeURIComponent(contact.email).replace(/%40/g, '@')})` : '',
315
+ contact.phone ? `[${linkText(contact.phone)}](tel:${contact.phone.replace(/[^\d+]/g, '')})` : '',
316
+ contact.handle ? inline(contact.handle) : '',
317
+ ].filter(Boolean)
318
+ out.push('## Reach them', 'To thank them or ask a follow-up question:', '', ...ways.map((w) => `- ${w}`), '')
319
+ }
320
+ const role = opts.filerRole ?? text(report.who?.role) ?? 'member'
321
+ // "Report <id>" is exact on purpose: the attachment relay reads the issue and requires this line before it saves a file.
322
+ out.push(FILED_BY, filedByLine(opts.filerName ?? report.filerName ?? 'A member', role || 'member', appName, text(report.id)))
323
+ return out.join('\n')
324
+ }
325
+
326
+ let said = words
327
+ let heard = transcript
328
+ const cut: TrailCut = { routes: 0, taps: 0, errors: 0 }
329
+ let body = build(said, heard, cut)
330
+ // Too long for GitHub: the trail gives way first (taps, then pages, then errors, oldest first), then the transcript,
331
+ // then the words, each with a note saying it was cut to fit.
332
+ for (const k of ['taps', 'routes', 'errors'] as const) {
333
+ while (body.length > BODY_LIMIT && cut[k] < full[k].length) {
334
+ cut[k] += Math.max(1, Math.ceil((full[k].length - cut[k]) / 2))
335
+ body = build(said, heard, cut)
336
+ }
337
+ }
338
+ if (body.length > BODY_LIMIT && heard) {
339
+ const keep = Math.max(0, heard.length - (body.length - BODY_LIMIT) - 200)
340
+ heard = `${heard.slice(0, keep)}\n\n(The transcript was cut here at ${keep} characters to fit GitHub's limit; the recording itself is attached.)`
341
+ body = build(said, heard, cut)
342
+ }
343
+ if (body.length > BODY_LIMIT && said) {
344
+ const keep = Math.max(0, said.length - (body.length - BODY_LIMIT) - 200)
345
+ said = `${said.slice(0, keep)}\n\n(Their words were cut here at ${keep} characters to fit GitHub's limit.)`
346
+ body = build(said, heard, cut)
347
+ }
348
+
349
+ // A title is plain text on GitHub; it still never pings anyone or links an issue.
350
+ const title = clip(firstSentence(words) || firstSentence(transcript) || `Suggestion from ${appName || 'an app'}`, 70).replace(/@(?=[\w-])/g, '@‍').replace(/#(?=\d)/g, '#‍')
351
+ const appLabel = opts.appLabel === null ? '' : opts.appLabel !== undefined ? slug(opts.appLabel) : appName ? slug(appName) : ''
352
+ const labels = [LABEL[text(report.kind)] ?? 'feedback', ...(appLabel ? [`app:${appLabel}`] : [])]
353
+ return { title, body, labels, carries }
354
+ }
@@ -0,0 +1,96 @@
1
+ // THE FILER'S SIDE OF A SUGGESTION RELAY. `relayFiler` talks to any relay running `createSuggestionRelay`, at whatever
2
+ // URL it is mounted on and with whatever header it checks. `canFileDirectly` says whether a filer's own token can see
3
+ // the repo (and so open an issue there); `fileSuggestion` uses it to choose between filing directly and the relay.
4
+ import { github, GitHubError, type Fetch } from './api.ts'
5
+ import { toBase64 } from './hosts.ts'
6
+
7
+ export interface RelayFile { name: string; type: string; bytes: Uint8Array | ArrayBuffer | string }
8
+ export interface RelayFiling {
9
+ repo: string
10
+ /** Which of the relay's apps this came from, by its id in the relay's own table (the relay names it and labels it). */
11
+ app?: string
12
+ /** The report, in the contract's shape. The relay never writes the account id into the issue. */
13
+ report: Record<string, unknown>
14
+ /** The name the issue says it was filed by. */
15
+ filerName?: string
16
+ appName?: string
17
+ appVersion?: string
18
+ transcript?: string
19
+ files?: RelayFile[]
20
+ /** True once the filer chose to file without what the privacy catch flagged (they replaced it themselves). */
21
+ redacted?: boolean
22
+ }
23
+ export interface RelayFiler {
24
+ /** Answers the issue; a repeat of the same report answers the same issue. A held report throws status 409 with `{ held, hits }`. */
25
+ file(input: RelayFiling): Promise<{ number: number; url: string }>
26
+ followUp(input: { repo: string; number: number; reportId: string; words?: string; transcript?: string; files?: RelayFile[]; redacted?: boolean }): Promise<{ id: number; url: string }>
27
+ status(items: { repo: string; number: number }[]): Promise<{ repo: string; number: number; status: string; reason?: string }[]>
28
+ /** What the relay can take (asked once, then kept). */
29
+ capabilities(): Promise<RelayCapabilities>
30
+ }
31
+
32
+ const files64 = (files: RelayFile[] = []) => files.map((f) => ({ name: f.name, type: f.type, bytes: typeof f.bytes === 'string' ? f.bytes : toBase64(f.bytes) }))
33
+ /** Files larger than this go to the relay staged (one signed PUT each) rather than inside the request. */
34
+ export const STAGE_OVER_BYTES = 2 * 2 ** 20
35
+ const sizeOf = (b: RelayFile['bytes']) => (typeof b === 'string' ? Math.floor((b.length * 3) / 4) : b instanceof Uint8Array ? b.length : b.byteLength)
36
+ const raw = (b: RelayFile['bytes']): Uint8Array => {
37
+ if (b instanceof Uint8Array) return b
38
+ if (b instanceof ArrayBuffer) return new Uint8Array(b)
39
+ const bin = atob(b); const out = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); return out
40
+ }
41
+ export interface RelayCapabilities { actions: string[]; staging: boolean; inlineBytes: number; maxBytes?: Record<string, number> }
42
+
43
+ /** `url` is where the relay is mounted; each action is posted to `<url>/<action>`. */
44
+ export function relayFiler({ url, headers = {}, fetch }: { url: string; headers?: Record<string, string>; fetch?: Fetch }): RelayFiler {
45
+ if (!/^https?:\/\//.test(url)) throw new Error(`${JSON.stringify(url)} is not a relay URL`)
46
+ const base = url.replace(/\/+$/, '')
47
+ const f = fetch ?? globalThis.fetch
48
+ const call = async (action: string, body: unknown) => {
49
+ const res = await f(`${base}/${action}`, { method: 'POST', headers: { ...headers, 'content-type': 'application/json' }, body: JSON.stringify(body) })
50
+ const text = await res.text()
51
+ let parsed: any = null
52
+ try { parsed = text ? JSON.parse(text) : null } catch { parsed = text }
53
+ if (!res.ok) throw new GitHubError(`the suggestion relay answered ${res.status} to ${action}`, res.status, parsed)
54
+ return parsed
55
+ }
56
+ let caps: Promise<RelayCapabilities> | null = null
57
+ const capabilities = () => (caps ??= (async () => {
58
+ const res = await f(`${base}/capabilities`, { method: 'GET', headers })
59
+ if (!res.ok) throw new GitHubError(`the suggestion relay answered ${res.status} to capabilities`, res.status, null)
60
+ return (await res.json()) as RelayCapabilities
61
+ })().catch((e) => { caps = null; throw e }))
62
+ /** Each file inline, or, past STAGE_OVER_BYTES, put to a link from upload-url first and named as staged. A file the
63
+ * relay cannot take at all is refused here, in words, before anything is filed. */
64
+ async function prepare(repo: string, reportId: string, files: RelayFile[] = []) {
65
+ if (!files.some((x) => sizeOf(x.bytes) > STAGE_OVER_BYTES)) return files64(files)
66
+ const c = await capabilities()
67
+ const out: Record<string, unknown>[] = []
68
+ for (const x of files) {
69
+ const size = sizeOf(x.bytes)
70
+ if (size <= Math.min(STAGE_OVER_BYTES, c.inlineBytes)) { out.push(...files64([x])); continue }
71
+ if (!c.staging) throw new Error(`${x.name} is larger than this relay takes in one request (${Math.round(c.inlineBytes / 2 ** 20)} MB), and it has no staging for larger files`)
72
+ const link = (await call('upload-url', { repo, reportId, name: x.name, type: x.type, bytes: size })) as { url: string; headers?: Record<string, string> }
73
+ const put = await f(link.url, { method: 'PUT', headers: link.headers ?? {}, body: raw(x.bytes) as BodyInit })
74
+ if (!put.ok) throw new GitHubError(`the staging upload of ${x.name} answered ${put.status}`, put.status, null)
75
+ out.push({ name: x.name, type: x.type, staged: x.name })
76
+ }
77
+ return out
78
+ }
79
+ return {
80
+ capabilities,
81
+ async file(input) { return call('file', { ...input, files: await prepare(input.repo, String(input.report.id), input.files) }) },
82
+ async followUp(input) { return call('follow-up', { ...input, files: await prepare(input.repo, input.reportId, input.files) }) },
83
+ async status(items) { return (await call('status', { items })).items },
84
+ }
85
+ }
86
+
87
+ /** True when this token can see the repo, so it can open an issue there itself; false on 401, 403 or 404 (or issues off). */
88
+ export async function canFileDirectly({ repo, token, fetch, api }: { repo: string; token: string; fetch?: Fetch; api?: string }): Promise<boolean> {
89
+ try {
90
+ const got = await github({ token, fetch, api }, 'GET', `/repos/${repo}`)
91
+ return got.body?.has_issues !== false
92
+ } catch (e) {
93
+ if (e instanceof GitHubError && [401, 403, 404].includes(e.status)) return false
94
+ throw e
95
+ }
96
+ }
@@ -0,0 +1,117 @@
1
+ // WHERE A SCREENSHOT OR RECORDING GOES (design: "Attachment parity for every filer"). GitHub has no API for attaching a
2
+ // file to an issue, so each file is committed to the repo's own `suggestion-attachments` branch and linked: it shows to
3
+ // exactly the people who can read the repo, and to nobody else. Two hosts ship: one for a filer that can push, one
4
+ // through the repo owner's own GitHub App, by way of a suggestion relay, for everyone who cannot.
5
+ import { isRepoName } from '../contract.mjs'
6
+ import { github, GitHubError, type Fetch } from './api.ts'
7
+
8
+ /** Which issue a file belongs to. The relay reads that issue and requires it to carry `Report <reportId>`. */
9
+ export interface AttachmentTarget { repo: string; issue: number; reportId: string }
10
+ export interface AttachmentFile { name: string; type: string; bytes: Uint8Array | ArrayBuffer }
11
+ export interface AttachmentHost { put(target: AttachmentTarget, file: AttachmentFile): Promise<{ url: string }> }
12
+
13
+ export const ATTACHMENTS_BRANCH = 'suggestion-attachments'
14
+ /** Up to this size a file goes through the contents API; past it, through the git data API (blob, tree, commit). */
15
+ export const CONTENTS_API_BYTES = 1_000_000
16
+ const REPORT_ID = /^r-\d{14}-[a-z0-9]{6}$/
17
+ const FILE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/
18
+
19
+ const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
20
+ /** Base64 with no dependency and no reliance on btoa's string rules. */
21
+ export function toBase64(bytes: Uint8Array | ArrayBuffer): string {
22
+ const b = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes)
23
+ let out = ''
24
+ for (let i = 0; i < b.length; i += 3) {
25
+ const n = (b[i] << 16) | ((b[i + 1] ?? 0) << 8) | (b[i + 2] ?? 0)
26
+ out += B64[(n >> 18) & 63] + B64[(n >> 12) & 63] + (i + 1 < b.length ? B64[(n >> 6) & 63] : '=') + (i + 2 < b.length ? B64[n & 63] : '=')
27
+ }
28
+ return out
29
+ }
30
+
31
+ /** Refuses anything that would write outside attachments/<report id>/<file>. */
32
+ function pathFor(t: AttachmentTarget, name: string): string {
33
+ if (!isRepoName(t.repo)) throw new Error(`${JSON.stringify(t.repo)} is not owner/name`)
34
+ if (!REPORT_ID.test(String(t.reportId))) throw new Error(`${JSON.stringify(t.reportId)} is not a report id`)
35
+ if (!FILE.test(name)) throw new Error(`${JSON.stringify(name)} is not a plain file name`)
36
+ return `attachments/${t.reportId}/${name}`
37
+ }
38
+ /** The link form that renders inline in a private issue, for the people who can read the repo. */
39
+ export const attachmentUrl = (repo: string, branch: string, path: string) => `https://github.com/${repo}/blob/${branch}/${path}?raw=true`
40
+
41
+ /** Commits each file through the contents API with a token that can push (a maintainer's Mac, an app server's own key).
42
+ * The branch is made the first time, as an orphan that shares no history with the code. A path already written is its link. */
43
+ export function branchHost({ repo, token, branch = ATTACHMENTS_BRANCH, fetch, refuseExisting = false }: { repo: string; token: string; branch?: string; fetch?: Fetch; /** Throw (409) instead of answering the link when the path is already written. */ refuseExisting?: boolean }): AttachmentHost {
44
+ const cfg = { token, fetch }
45
+ let ready: Promise<void> | null = null
46
+ const ensure = () => (ready ??= (async () => {
47
+ try { await github(cfg, 'GET', `/repos/${repo}/git/ref/heads/${branch}`); return } catch (e) { if (!(e instanceof GitHubError) || e.status !== 404) throw e }
48
+ const tree = await github(cfg, 'POST', `/repos/${repo}/git/trees`, { tree: [{ path: 'README.md', mode: '100644', type: 'blob', content: 'Screenshots and recordings attached to suggestions filed as issues on this repo, one folder per report. Nothing here is code.\n' }] })
49
+ const commit = await github(cfg, 'POST', `/repos/${repo}/git/commits`, { message: 'Start the suggestion attachments branch', tree: tree.body.sha, parents: [] })
50
+ try { await github(cfg, 'POST', `/repos/${repo}/git/refs`, { ref: `refs/heads/${branch}`, sha: commit.body.sha }) } catch (e) { if (!(e instanceof GitHubError) || e.status !== 422) throw e }
51
+ })().catch((e) => { ready = null; throw e }))
52
+ /** A file past the contents API's comfortable size, through the git data API: a blob, a tree on top of the branch's
53
+ * own, a commit, and the branch moved to it (once more if another write moved it first). Up to GitHub's 100 MB. */
54
+ async function putLarge(path: string, file: AttachmentFile, reportId: string) {
55
+ try {
56
+ await github(cfg, 'GET', `/repos/${repo}/contents/${path}?ref=${branch}`)
57
+ if (refuseExisting) throw new GitHubError(`${path} is already written`, 409, null)
58
+ return // written once: its link stands
59
+ } catch (e) { if (!(e instanceof GitHubError) || e.status !== 404) throw e }
60
+ const blob = (await github(cfg, 'POST', `/repos/${repo}/git/blobs`, { content: toBase64(file.bytes), encoding: 'base64' })).body.sha as string
61
+ for (let attempt = 0; ; attempt++) {
62
+ const head = (await github(cfg, 'GET', `/repos/${repo}/git/ref/heads/${branch}`)).body.object.sha as string
63
+ const base = (await github(cfg, 'GET', `/repos/${repo}/git/commits/${head}`)).body.tree.sha as string
64
+ const tree = (await github(cfg, 'POST', `/repos/${repo}/git/trees`, { base_tree: base, tree: [{ path, mode: '100644', type: 'blob', sha: blob }] })).body.sha as string
65
+ const commit = (await github(cfg, 'POST', `/repos/${repo}/git/commits`, { message: `Attach ${file.name} to ${reportId}`, tree, parents: [head] })).body.sha as string
66
+ try { await github(cfg, 'PATCH', `/repos/${repo}/git/refs/heads/${branch}`, { sha: commit }); return }
67
+ catch (e) { if (!(e instanceof GitHubError) || e.status !== 422 || attempt >= 2) throw e } // someone else moved the branch: build on theirs
68
+ }
69
+ }
70
+ return {
71
+ async put(target, file) {
72
+ if (target.repo !== repo) throw new Error(`this host writes to ${repo}, not ${target.repo}`)
73
+ const path = pathFor(target, file.name)
74
+ await ensure()
75
+ const size = file.bytes instanceof Uint8Array ? file.bytes.length : file.bytes.byteLength
76
+ if (size > CONTENTS_API_BYTES) {
77
+ await putLarge(path, file, target.reportId)
78
+ return { url: attachmentUrl(repo, branch, path) }
79
+ }
80
+ try {
81
+ await github(cfg, 'PUT', `/repos/${repo}/contents/${path}`, { message: `Attach ${file.name} to ${target.reportId}`, content: toBase64(file.bytes), branch })
82
+ } catch (e) {
83
+ if (!(e instanceof GitHubError) || e.status !== 422) throw e
84
+ // A 422 means "already there" only if it is there: look, and pass any other refusal on as it came.
85
+ let there = false
86
+ try { await github(cfg, 'GET', `/repos/${repo}/contents/${path}?ref=${branch}`); there = true } catch (g) { if (!(g instanceof GitHubError) || g.status !== 404) throw g }
87
+ if (!there) throw e
88
+ if (refuseExisting) throw new GitHubError(`${path} is already written`, 409, e.body)
89
+ }
90
+ return { url: attachmentUrl(repo, branch, path) }
91
+ },
92
+ }
93
+ }
94
+
95
+ /** For a filer who cannot push: the file goes to an attachment relay (any URL running `createAttachRelay`, with whatever
96
+ * header proves who is asking), which checks the named issue carries this report, saves the file through its own
97
+ * GitHub App, and answers the link. */
98
+ export function relayHost({ url, headers = {}, fetch }: { url: string; headers?: Record<string, string>; fetch?: Fetch }): AttachmentHost {
99
+ const f = fetch ?? globalThis.fetch
100
+ if (!/^https?:\/\//.test(url)) throw new Error(`${JSON.stringify(url)} is not a relay URL`)
101
+ return {
102
+ async put(target, file) {
103
+ pathFor(target, file.name)
104
+ const res = await f(url, {
105
+ method: 'POST',
106
+ headers: { ...headers, 'content-type': 'application/json' },
107
+ body: JSON.stringify({ repo: target.repo, issue: target.issue, reportId: target.reportId, name: file.name, type: file.type, bytes: toBase64(file.bytes) }),
108
+ })
109
+ const text = await res.text()
110
+ let body: any = null
111
+ try { body = text ? JSON.parse(text) : null } catch { body = text }
112
+ if (!res.ok) throw new GitHubError(`the attachment relay answered ${res.status}`, res.status, body)
113
+ if (typeof body?.url !== 'string' || !body.url.startsWith(`https://github.com/${target.repo}/`)) throw new Error('the attachment relay did not answer a github.com link in this repo')
114
+ return { url: body.url }
115
+ },
116
+ }
117
+ }
@@ -0,0 +1,15 @@
1
+ // "@supersuit/suggest-a-change/github": a suggestion becomes one ordinary GitHub issue. Zero runtime dependencies,
2
+ // web-standard fetch (injectable). Used straight from an app's server (createReportHandlers' `github` option) or by the
3
+ // owner's Mac with `gh`'s token.
4
+ export { privacyCatch, redact, mayFileAsIs, normalize } from './privacy.ts'
5
+ export type { Hit, HitKind, Person, CatchOptions } from './privacy.ts'
6
+ export { composeIssue, whatGoes, destinationWords, pieces, BODY_LIMIT, filedByLine, endsWithFiledBy, FILED_BY } from './compose.ts'
7
+ export type { Piece, Destination, Draft, ComposeOptions, ComposedIssue } from './compose.ts'
8
+ export { fileIssue, updateIssue, addFollowUp, issueStates, issueStatus, fileSuggestion, attachPending, github, GitHubError } from './api.ts'
9
+ export type { GitHubConfig, FilingConfig, IssueState, FileSuggestionInput, FiledSuggestion, Fetch } from './api.ts'
10
+ export { branchHost, relayHost, toBase64, attachmentUrl, ATTACHMENTS_BRANCH, CONTENTS_API_BYTES } from './hosts.ts'
11
+ export type { AttachmentHost, AttachmentTarget, AttachmentFile } from './hosts.ts'
12
+ export { createSuggestionRelay, createAttachRelay, memoryUsage, RELAY_LIMITS, RELAY_VOICE_BYTES, FILING_LEASE_MS } from './relay.ts'
13
+ export type { SuggestionRelayOptions, AttachRelayOptions, RelayLimits, RelayUsage, RelayIdentity, Reservation, FiledRecord, StagingAdapter } from './relay.ts'
14
+ export { relayFiler, canFileDirectly, STAGE_OVER_BYTES } from './filer.ts'
15
+ export type { RelayFiler, RelayFiling, RelayFile, RelayCapabilities } from './filer.ts'