@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,164 @@
1
+ // THE GITHUB CALLS. Web-standard fetch only, injectable so tests and any runtime can pass their own. The token is the
2
+ // filer's (`gh auth token` on a Mac) or an app server's own; it is sent to api.github.com and nowhere else.
3
+ import { isRepoName } from '../contract.mjs';
4
+ import { composeIssue } from './compose.js';
5
+ /** An issue carries at most this many labels (the composer writes two). */
6
+ const MAX_LABELS = 10;
7
+ import { canFileDirectly } from './filer.js';
8
+ export class GitHubError extends Error {
9
+ status;
10
+ body;
11
+ constructor(message, status, body) { super(message); this.name = 'GitHubError'; this.status = status; this.body = body; }
12
+ }
13
+ const API = 'https://api.github.com';
14
+ function repoOf(repo) {
15
+ if (!isRepoName(repo))
16
+ throw new Error(`${JSON.stringify(repo)} is not owner/name`);
17
+ return repo;
18
+ }
19
+ /** One REST call. Answers the parsed body, or throws a GitHubError carrying GitHub's status. */
20
+ export async function github(cfg, method, path, body) {
21
+ const f = cfg.fetch ?? globalThis.fetch;
22
+ const res = await f(`${cfg.api ?? API}${path}`, {
23
+ method,
24
+ headers: {
25
+ accept: 'application/vnd.github+json',
26
+ authorization: `Bearer ${cfg.token}`,
27
+ 'x-github-api-version': '2022-11-28',
28
+ ...(body !== undefined ? { 'content-type': 'application/json' } : {}),
29
+ },
30
+ ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
31
+ });
32
+ const textBody = await res.text();
33
+ let parsed = null;
34
+ try {
35
+ parsed = textBody ? JSON.parse(textBody) : null;
36
+ }
37
+ catch {
38
+ parsed = textBody;
39
+ }
40
+ if (!res.ok)
41
+ throw new GitHubError(`GitHub answered ${res.status} to ${method} ${path}`, res.status, parsed);
42
+ return { status: res.status, body: parsed };
43
+ }
44
+ const COLORS = { bug: 'd73a4a', enhancement: 'a2eeef', feedback: 'c5def5' };
45
+ /** Opens one issue. Missing labels are created first; one that exists (422) or that this account may not create
46
+ * (403/404: a read-only filer) is skipped, and GitHub then keeps only the labels the filer may set. */
47
+ export async function fileIssue(cfg, issue) {
48
+ const repo = repoOf(cfg.repo);
49
+ const labels = issue.labels.filter((l) => typeof l === 'string' && l.length && l.length <= 50).slice(0, MAX_LABELS);
50
+ for (const name of labels) {
51
+ try {
52
+ await github(cfg, 'POST', `/repos/${repo}/labels`, { name, color: COLORS[name] ?? 'ededed' });
53
+ }
54
+ catch (e) {
55
+ if (!(e instanceof GitHubError) || ![403, 404, 422].includes(e.status))
56
+ throw e;
57
+ }
58
+ }
59
+ const got = await github(cfg, 'POST', `/repos/${repo}/issues`, { title: issue.title, body: issue.body, labels });
60
+ return { number: got.body.number, url: got.body.html_url };
61
+ }
62
+ /** Replaces an issue's body (the filer's own issue: read access is enough to edit what you opened). */
63
+ export async function updateIssue(cfg, number, change) {
64
+ await github(cfg, 'PATCH', `/repos/${repoOf(cfg.repo)}/issues/${number}`, { body: change.body });
65
+ }
66
+ /** A follow-up the person added later, as a comment on the same issue. */
67
+ export async function addFollowUp(cfg, number, body) {
68
+ const got = await github(cfg, 'POST', `/repos/${repoOf(cfg.repo)}/issues/${number}/comments`, { body });
69
+ return { id: got.body.id, url: got.body.html_url };
70
+ }
71
+ /** The state of many issues in one GraphQL call (in chunks of 50). Repo names travel as variables, never in the query. */
72
+ export async function issueStates(cfg, issues) {
73
+ const out = [];
74
+ for (let start = 0; start < issues.length; start += 50) {
75
+ const chunk = issues.slice(start, start + 50);
76
+ const vars = {};
77
+ const decl = [];
78
+ const parts = [];
79
+ chunk.forEach((it, i) => {
80
+ const [owner, name] = repoOf(it.repo).split('/');
81
+ if (!Number.isInteger(it.number) || it.number < 1)
82
+ throw new Error(`${it.number} is not an issue number`);
83
+ vars[`o${i}`] = owner;
84
+ vars[`n${i}`] = name;
85
+ vars[`k${i}`] = it.number;
86
+ decl.push(`$o${i}: String!, $n${i}: String!, $k${i}: Int!`);
87
+ parts.push(`i${i}: repository(owner: $o${i}, name: $n${i}) { issue(number: $k${i}) { state stateReason comments(last: 1) { nodes { body } } } }`);
88
+ });
89
+ const query = `query(${decl.join(', ')}) { ${parts.join(' ')} }`;
90
+ const got = await github(cfg, 'POST', '/graphql', { query, variables: vars });
91
+ const data = got.body?.data;
92
+ if (!data)
93
+ throw new GitHubError('GitHub answered no data to the issue state query', got.status, got.body);
94
+ chunk.forEach((it, i) => {
95
+ const issue = data[`i${i}`]?.issue;
96
+ if (!issue) {
97
+ out.push({ repo: it.repo, number: it.number, state: null, stateReason: null, missing: true });
98
+ return;
99
+ }
100
+ const row = { repo: it.repo, number: it.number, state: issue.state, stateReason: issue.stateReason ?? null };
101
+ const last = issue.comments?.nodes?.at(-1)?.body;
102
+ const line = typeof last === 'string' ? last.trim().split('\n')[0].trim() : '';
103
+ if (String(issue.state).toUpperCase() === 'CLOSED' && line)
104
+ row.reason = line;
105
+ out.push(row);
106
+ });
107
+ }
108
+ return out;
109
+ }
110
+ /** GitHub's state onto the person's three: open, done (closed as completed), not planned (as not planned, or a duplicate). */
111
+ export function issueStatus({ state, stateReason }) {
112
+ if (String(state).toUpperCase() !== 'CLOSED')
113
+ return 'open';
114
+ return ['NOT_PLANNED', 'DUPLICATE'].includes(String(stateReason).toUpperCase()) ? 'not-planned' : 'done';
115
+ }
116
+ const reportFiles = (r) => [...(r.media ?? []), ...(r.voice ?? [])].map((f) => f.file);
117
+ /** Saves the files the issue is still waiting for, then edits their links into the body. Call again later with the
118
+ * same `attached` to finish what failed: the issue keeps saying a file is coming until it is there. */
119
+ export async function attachPending(cfg, input) {
120
+ const attached = { ...(input.attached ?? {}) };
121
+ const wanted = new Set(reportFiles(input.report));
122
+ let added = 0;
123
+ if (input.attachments) {
124
+ for (const f of input.files) {
125
+ if (!wanted.has(f.name) || attached[f.name])
126
+ continue;
127
+ try {
128
+ attached[f.name] = (await input.attachments.put({ repo: cfg.repo, issue: input.number, reportId: String(input.report.id), }, f)).url;
129
+ added++;
130
+ }
131
+ catch { /* left pending: the issue still says it is coming, and a later call retries it */ }
132
+ }
133
+ }
134
+ if (added)
135
+ await updateIssue(cfg, input.number, { body: composeIssue(input.report, { ...input.compose, attachmentUrls: attached }).body });
136
+ return { attached, pending: [...wanted].filter((n) => !attached[n]) };
137
+ }
138
+ /** The whole filing. With a token that can see the repo it files directly, in the order that never shows an incomplete
139
+ * issue as complete: open the issue saying what is being added (opening it is the proof the filer can file there),
140
+ * save each file against that issue, then edit the links in. With no token, or one that cannot see the repo, and a
141
+ * `relay`, the relay files it (the files, then the issue, as its App) for someone with no access at all. */
142
+ export async function fileSuggestion(cfg, input) {
143
+ const direct = cfg.token ? (!cfg.relay || await canFileDirectly({ repo: cfg.repo, token: cfg.token, fetch: cfg.fetch, api: cfg.api })) : false;
144
+ if (!direct) {
145
+ if (!cfg.relay)
146
+ throw new Error('fileSuggestion needs a token that can file on the repo, or a relay');
147
+ const made = await cfg.relay.file({
148
+ repo: cfg.repo, ...(cfg.app ? { app: cfg.app } : {}), report: input.report, files: input.files,
149
+ ...(input.compose?.filerName ? { filerName: input.compose.filerName } : {}),
150
+ ...(input.compose?.appName ? { appName: input.compose.appName } : {}),
151
+ ...(input.compose?.appVersion ? { appVersion: input.compose.appVersion } : {}),
152
+ ...(input.compose?.transcript ? { transcript: input.compose.transcript } : {}),
153
+ });
154
+ return { ...made, attached: {}, pending: [], via: 'relay' };
155
+ }
156
+ const own = { ...cfg, token: cfg.token };
157
+ const hasFiles = reportFiles(input.report).length > 0;
158
+ const first = composeIssue(input.report, { ...input.compose, pending: hasFiles });
159
+ const issue = await fileIssue(own, first);
160
+ if (!hasFiles)
161
+ return { ...issue, attached: {}, pending: [], via: 'direct' };
162
+ const rest = await attachPending(own, { ...input, number: issue.number });
163
+ return { ...issue, ...rest, via: 'direct' };
164
+ }
@@ -0,0 +1,152 @@
1
+ /** A piece the report carries. `ctx:<key>` is one context item; routes, taps and errors are the trail; contact is how to reach them. */
2
+ export type Piece = 'words' | 'voice' | 'images' | 'video' | 'where' | 'app' | 'device' | 'time' | 'routes' | 'taps' | 'errors' | 'contact' | 'filer' | `ctx:${string}`;
3
+ export interface ContextItem {
4
+ key: string;
5
+ label: string;
6
+ value: string;
7
+ why: string;
8
+ }
9
+ export interface DraftContext {
10
+ items?: ContextItem[];
11
+ routes?: {
12
+ route: string;
13
+ at: string;
14
+ }[];
15
+ taps?: {
16
+ label: string;
17
+ at: string;
18
+ }[];
19
+ errors?: {
20
+ message: string;
21
+ frame?: string;
22
+ at: string;
23
+ }[];
24
+ }
25
+ export interface Contact {
26
+ email?: string;
27
+ phone?: string;
28
+ handle?: string;
29
+ }
30
+ /** One line of the sheet's list. `optional` rows can be left out; `on` is whether they go by default, `reason` why not. */
31
+ export interface GoesItem {
32
+ key: Piece;
33
+ text: string;
34
+ value?: string;
35
+ why?: string;
36
+ optional?: boolean;
37
+ on?: boolean;
38
+ reason?: string;
39
+ }
40
+ export interface Destination {
41
+ /** owner/name of the repo the issue files on. */
42
+ repo: string;
43
+ /** True for a public repo: the sheet says so in the danger colour. */
44
+ public: boolean;
45
+ /** The GitHub login the issue is filed from. */
46
+ account: string;
47
+ /** False when files cannot reach this repo yet (no App installed and the filer cannot push): they wait. */
48
+ attaches: boolean;
49
+ /** False when nobody transcribes voice notes (an app's own server): the list does not promise a transcript. */
50
+ transcribes?: boolean;
51
+ /** The largest file this destination takes; a larger one is named in the list as staying on the phone. */
52
+ maxInlineBytes?: number;
53
+ }
54
+ /** What composeIssue and whatGoes read: a report in the contract's shape, plus two optional names for the list. */
55
+ export interface Draft {
56
+ id?: string;
57
+ kind?: string;
58
+ words?: string;
59
+ who?: {
60
+ member?: string;
61
+ role?: string;
62
+ contact?: Contact;
63
+ };
64
+ /** Everything else shared so it can be fixed (the module's `collectContext`, and the app's own items). */
65
+ context?: DraftContext;
66
+ voice?: {
67
+ file: string;
68
+ ms?: number;
69
+ }[];
70
+ media?: {
71
+ file: string;
72
+ type: string;
73
+ bytes?: number;
74
+ ms?: number;
75
+ }[];
76
+ where?: {
77
+ screen?: string;
78
+ route?: string;
79
+ };
80
+ diagnostics?: {
81
+ appVersion?: string;
82
+ at?: string;
83
+ device?: string;
84
+ screen?: string;
85
+ [k: string]: unknown;
86
+ };
87
+ /** The sender's name as the issue will say it (the list's "your name, ..."). */
88
+ filerName?: string;
89
+ /** The app's name, for the list's "Helm 2.14.0". */
90
+ appName?: string;
91
+ [k: string]: unknown;
92
+ }
93
+ export interface ComposeOptions {
94
+ appName?: string;
95
+ /** The app label's host-given name (`app:<appLabel>`); null for none. Without it the label is made from appName. */
96
+ appLabel?: string | null;
97
+ appVersion?: string;
98
+ filerName?: string;
99
+ filerRole?: string;
100
+ /** file name -> its link, from an AttachmentHost. A file with no link yet is written as still being added. */
101
+ attachmentUrls?: Record<string, string>;
102
+ /** The voice notes' transcript, made on the owner's Mac. */
103
+ transcript?: string;
104
+ /** The first body, written before any file is saved: every attachment is "being added". */
105
+ pending?: boolean;
106
+ }
107
+ export interface ComposedIssue {
108
+ title: string;
109
+ body: string;
110
+ labels: string[];
111
+ carries: Piece[];
112
+ }
113
+ /** GitHub refuses a body of 65536 characters or more; this leaves room. */
114
+ export declare const BODY_LIMIT = 65000;
115
+ /** Which pieces a report carries, in the order the issue and the list both use. */
116
+ export declare function pieces(d: Draft): Piece[];
117
+ export declare const TRAIL_WORDS: {
118
+ routes: {
119
+ label: string;
120
+ heading: string;
121
+ why: string;
122
+ };
123
+ taps: {
124
+ label: string;
125
+ heading: string;
126
+ why: string;
127
+ };
128
+ errors: {
129
+ label: string;
130
+ heading: string;
131
+ why: string;
132
+ };
133
+ };
134
+ export declare const CONTACT_WHY = "so whoever fixes it can thank you or ask a follow-up question";
135
+ export declare const PUBLIC_CONTACT_REASON = "this repo is public, so anyone could read it";
136
+ /** The sheet's list, in plain words, one item per piece the issue will carry, with the value and why for each piece
137
+ * of context. Anything but what the person added, the app's version and their name can be left out (`optional`). */
138
+ export declare function whatGoes(draft: Draft, destination: Destination): GoesItem[];
139
+ /** The lines around the list: where it files, who can read it there, and whose account files it. */
140
+ export declare function destinationWords(d: Destination): {
141
+ headline: string;
142
+ privacy: string;
143
+ danger: boolean;
144
+ account: string;
145
+ };
146
+ /** THE LAST LINE OF EVERY ISSUE, written and recognized by these two and nothing else: a relay accepts an attachment for
147
+ * a report only on an issue that ends with this line for it, so the writer and the reader must never drift apart. */
148
+ export declare const FILED_BY = "## Filed by";
149
+ export declare function filedByLine(filer: string, role: string, appName: string | undefined, reportId: string): string;
150
+ /** Whether an issue's body ends with the composer's own Filed by line for this report, in either of its forms. */
151
+ export declare function endsWithFiledBy(body: string, reportId: string): boolean;
152
+ export declare function composeIssue(report: Draft, opts?: ComposeOptions): ComposedIssue;
@@ -0,0 +1,309 @@
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
+ /** GitHub refuses a body of 65536 characters or more; this leaves room. */
6
+ export const BODY_LIMIT = 65_000;
7
+ const LABEL = { change: 'enhancement', broken: 'bug', feedback: 'feedback' };
8
+ const EMAIL = /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g;
9
+ const isImage = (m) => String(m.type).startsWith('image/');
10
+ const isVideo = (m) => String(m.type).startsWith('video/');
11
+ const text = (v) => (typeof v === 'string' ? v : '');
12
+ const plural = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
13
+ const list = (parts) => (parts.length <= 1 ? parts.join('') : `${parts.slice(0, -1).join(', ')} and ${parts[parts.length - 1]}`);
14
+ const contactOf = (d) => {
15
+ const c = d.who?.contact;
16
+ return c && (c.email || c.phone || c.handle) ? c : null;
17
+ };
18
+ /** Whether a report carries context; with it, the context table stands in for the About line. */
19
+ const hasContext = (d) => Boolean(d.context && ((d.context.items ?? []).length || (d.context.routes ?? []).length || (d.context.taps ?? []).length || (d.context.errors ?? []).length));
20
+ /** Which pieces a report carries, in the order the issue and the list both use. */
21
+ export function pieces(d) {
22
+ const media = d.media ?? [];
23
+ const g = d.diagnostics ?? {};
24
+ const out = [];
25
+ if (text(d.words).trim())
26
+ out.push('words');
27
+ if ((d.voice ?? []).length)
28
+ out.push('voice');
29
+ if (media.some(isImage))
30
+ out.push('images');
31
+ if (media.some(isVideo))
32
+ out.push('video');
33
+ if (text(d.where?.screen) || text(d.where?.route))
34
+ out.push('where');
35
+ if (text(g.appVersion))
36
+ out.push('app');
37
+ if (hasContext(d)) {
38
+ for (const i of d.context.items ?? [])
39
+ out.push(`ctx:${i.key}`);
40
+ if ((d.context.routes ?? []).length)
41
+ out.push('routes');
42
+ if ((d.context.taps ?? []).length)
43
+ out.push('taps');
44
+ if ((d.context.errors ?? []).length)
45
+ out.push('errors');
46
+ }
47
+ else {
48
+ if (text(g.device) || text(g.screen))
49
+ out.push('device');
50
+ if (text(g.at))
51
+ out.push('time');
52
+ }
53
+ if (contactOf(d))
54
+ out.push('contact');
55
+ out.push('filer');
56
+ return out;
57
+ }
58
+ export const TRAIL_WORDS = {
59
+ routes: { label: 'Pages you visited before this', heading: 'Pages visited before this', why: 'so the fixer can retrace how you got here' },
60
+ 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' },
61
+ 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' },
62
+ };
63
+ export const CONTACT_WHY = 'so whoever fixes it can thank you or ask a follow-up question';
64
+ export const PUBLIC_CONTACT_REASON = 'this repo is public, so anyone could read it';
65
+ const contactValue = (c) => [c.email, c.phone, c.handle].filter(Boolean).join(' · ');
66
+ /** The sheet's list, in plain words, one item per piece the issue will carry, with the value and why for each piece
67
+ * of context. Anything but what the person added, the app's version and their name can be left out (`optional`). */
68
+ export function whatGoes(draft, destination) {
69
+ const ctxItems = new Map((draft.context?.items ?? []).map((i) => [`ctx:${i.key}`, i]));
70
+ const contact = contactOf(draft);
71
+ const trailCount = (k) => (draft.context?.[k] ?? []).length;
72
+ const trailUnit = { routes: 'page', taps: 'tap', errors: 'error' };
73
+ return basicGoes(draft, destination).map((g) => {
74
+ const c = ctxItems.get(g.key);
75
+ if (c)
76
+ return { key: g.key, text: c.label, value: c.value, why: c.why, optional: true, on: true };
77
+ if (g.key === 'routes' || g.key === 'taps' || g.key === 'errors') {
78
+ const w = TRAIL_WORDS[g.key];
79
+ return { key: g.key, text: w.label, value: plural(trailCount(g.key), trailUnit[g.key]), why: w.why, optional: true, on: true };
80
+ }
81
+ if (g.key === 'contact' && contact) {
82
+ 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 } : {}) };
83
+ }
84
+ return g;
85
+ });
86
+ }
87
+ function basicGoes(draft, destination) {
88
+ const media = draft.media ?? [];
89
+ const g = draft.diagnostics ?? {};
90
+ const kept = destination.attaches
91
+ ? `saved to the suggestion-attachments branch of ${destination.repo}`
92
+ : `added once ${destination.repo} can take attachments`;
93
+ const voices = (draft.voice ?? []).length;
94
+ // A file the destination cannot take is said so in advance, by name, before anything is sent.
95
+ const max = destination.maxInlineBytes;
96
+ const mb = (n) => (n < 2 ** 20 ? `${Math.max(1, Math.round(n / 1024))} KB` : `${Math.round((n / 2 ** 20) * 10) / 10} MB`);
97
+ const tooBig = (files) => {
98
+ const over = max ? files.filter((m) => (m.bytes ?? 0) > max) : [];
99
+ 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` : '';
100
+ };
101
+ const say = {
102
+ words: () => 'your words',
103
+ voice: () => destination.transcribes === false
104
+ ? (voices === 1 ? 'your voice note' : `your ${voices} voice notes`)
105
+ : (voices === 1 ? 'your voice note and its transcript' : `your ${voices} voice notes and their transcripts`),
106
+ images: () => `${plural(media.filter(isImage).length, 'screenshot')}, ${kept}${tooBig(media.filter(isImage))}`,
107
+ video: () => `${plural(media.filter(isVideo).length, 'video')}, ${kept}${tooBig(media.filter(isVideo))}`,
108
+ where: () => `this screen (${[text(draft.where?.screen), text(draft.where?.route)].filter(Boolean).join(', ')})`,
109
+ app: () => `${draft.appName || 'the app'} ${text(g.appVersion)}`,
110
+ device: () => [text(g.device), text(g.screen)].filter(Boolean).join(', '),
111
+ time: () => 'the time',
112
+ filer: () => (draft.filerName ? `your name, ${draft.filerName}` : 'that it came from you'),
113
+ };
114
+ return pieces(draft).map((key) => ({ key, text: say[key]?.() ?? key }));
115
+ }
116
+ /** The lines around the list: where it files, who can read it there, and whose account files it. */
117
+ export function destinationWords(d) {
118
+ return {
119
+ headline: `Sending this files an issue on ${d.repo}`,
120
+ privacy: d.public ? 'public: anyone on the internet can read this' : `private: people with access to ${d.repo}'s code can read it`,
121
+ danger: Boolean(d.public),
122
+ account: `Filed from ${d.account}'s GitHub account.`,
123
+ };
124
+ }
125
+ /** "2026-10-26T17:04:12Z" -> "2026-10-26 17:04 UTC"; anything unreadable as it is. */
126
+ function when(at) {
127
+ const t = Date.parse(at);
128
+ if (!Number.isFinite(t))
129
+ return inline(at); // not a time: shown, escaped, as any other line
130
+ const iso = new Date(t).toISOString();
131
+ return `${iso.slice(0, 10)} ${iso.slice(11, 16)} UTC`;
132
+ }
133
+ function firstSentence(s) {
134
+ const line = s.trim().split('\n')[0].trim();
135
+ const m = /^.*?[.!?](?=\s|$)/.exec(line);
136
+ return (m ? m[0] : line).trim();
137
+ }
138
+ function clip(s, max) {
139
+ if (s.length <= max)
140
+ return s;
141
+ let cut = s.slice(0, max - 1);
142
+ const space = cut.lastIndexOf(' ');
143
+ if (space > max / 2)
144
+ cut = cut.slice(0, space);
145
+ return `${cut.trimEnd()}…`;
146
+ }
147
+ /** Text that cannot become markup in the issue. */
148
+ const html = (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
149
+ /** A table cell: no markup, no pipe that ends the cell, on one line. */
150
+ const cell = (s) => inline(s).replace(/\|/g, '\\|');
151
+ /** One line of report-borne text, made inert: no HTML, no emphasis, code, link or image syntax, no @mention, no #123
152
+ * reference, on one line. Every string a report brings that is not inside a fence goes through this. */
153
+ const inline = (s) => html(String(s)).replace(/[\\`*_[\]~!]/g, '\\$&').replace(/@(?=[\w-])/g, '@\u200d').replace(/#(?=\d)/g, '#\u200d').replace(/\r?\n/g, ' ');
154
+ /** Text a person wrote, shown as they wrote it inside a fence longer than any backtick run in it, so nothing in it renders. */
155
+ const fence = (s) => {
156
+ const run = Math.max(0, ...[...s.matchAll(/`+/g)].map((m) => m[0].length));
157
+ const f = '`'.repeat(Math.max(3, run + 1));
158
+ return `${f}\n${s}\n${f}`;
159
+ };
160
+ /** Link text with no markdown of its own (an email address keeps its @: it is not a mention inside link text). */
161
+ const linkText = (s) => html(String(s)).replace(/[\\`*_[\]~!]/g, '\\$&').replace(/\r?\n/g, ' ');
162
+ /** Only an https link with nothing that could end or break the link may become one; anything else is not linked. */
163
+ const safeUrl = (u) => (typeof u === 'string' && /^https:\/\/[^\s()<>"'`]+$/.test(u) ? u : null);
164
+ /** THE LAST LINE OF EVERY ISSUE, written and recognized by these two and nothing else: a relay accepts an attachment for
165
+ * a report only on an issue that ends with this line for it, so the writer and the reader must never drift apart. */
166
+ export const FILED_BY = '## Filed by';
167
+ export function filedByLine(filer, role, appName, reportId) {
168
+ return `${inline(filer)} (${inline(role)}), through Suggest a change in ${appName ? `the ${inline(appName)}` : 'an app'}. Report ${inline(reportId)}.`;
169
+ }
170
+ /** Whether an issue's body ends with the composer's own Filed by line for this report, in either of its forms. */
171
+ export function endsWithFiledBy(body, reportId) {
172
+ if (!/^r-\d{14}-[a-z0-9]{6}$/.test(reportId))
173
+ return false;
174
+ const lines = String(body).replace(/\s+$/, '').split('\n');
175
+ const last = lines[lines.length - 1] ?? '';
176
+ return lines[lines.length - 2]?.trim() === FILED_BY && new RegExp(`^\\S.* \\(.+\\), through Suggest a change in (the .+|an app)\\. Report ${reportId}\\.$`).test(last);
177
+ }
178
+ /** "2026-10-26T17:04:05Z" -> "17:04:05 UTC"; anything else as it is. */
179
+ const clock = (at) => { const t = Date.parse(at); return Number.isFinite(t) ? `${new Date(t).toISOString().slice(11, 19)} UTC` : inline(String(at)); };
180
+ const slug = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
181
+ export function composeIssue(report, opts = {}) {
182
+ // 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.
183
+ const key = text(report.who?.member);
184
+ const scrub = (s) => {
185
+ let out = s.replace(EMAIL, '[removed]');
186
+ if (key.length >= 12)
187
+ out = out.split(key).join('[removed]');
188
+ return out;
189
+ };
190
+ const words = scrub(text(report.words).trim());
191
+ const transcript = scrub(text(opts.transcript).trim());
192
+ // Only links the host produced, and only plain https ones, become images or links; anything else is still "being added".
193
+ const urls = opts.pending ? {} : Object.fromEntries(Object.entries(opts.attachmentUrls ?? {}).flatMap(([k, u]) => (safeUrl(u) ? [[k, u]] : [])));
194
+ const media = report.media ?? [];
195
+ const voice = report.voice ?? [];
196
+ const g = report.diagnostics ?? {};
197
+ const appName = opts.appName ?? report.appName;
198
+ const has = new Set(pieces(report));
199
+ const carries = pieces(report);
200
+ // Everything not yet linked, counted, so the issue never claims to be complete before it is.
201
+ const missing = [...media, ...voice].filter((f) => !urls[f.file]);
202
+ const waitImages = media.filter((m) => isImage(m) && !urls[m.file]).length;
203
+ const waitVideos = media.filter((m) => isVideo(m) && !urls[m.file]).length;
204
+ const waitVoice = voice.filter((v) => !urls[v.file]).length;
205
+ const coming = missing.length
206
+ ? `${list([waitImages ? plural(waitImages, 'screenshot') : '', waitVideos ? plural(waitVideos, 'video') : '', waitVoice ? plural(waitVoice, 'voice recording') : ''].filter(Boolean))} ${missing.length === 1 ? 'is' : 'are'} being added.`
207
+ : '';
208
+ const full = { routes: report.context?.routes ?? [], taps: report.context?.taps ?? [], errors: report.context?.errors ?? [] };
209
+ const contact = contactOf(report);
210
+ // The trail is kept newest-last; a cut keeps the newest.
211
+ const shown = (k, cut) => full[k].slice(cut[k]);
212
+ const build = (said, heard, cut = { routes: 0, taps: 0, errors: 0 }) => {
213
+ const out = [];
214
+ if (has.has('words'))
215
+ out.push('## What they said', fence(said), '');
216
+ if (has.has('voice')) {
217
+ out.push(transcript ? "## Voice note (transcribed on the owner's Mac)" : '## Voice note', heard ? fence(heard) : 'Not transcribed.');
218
+ const links = voice.filter((v) => urls[v.file]).map((v) => `[${inline(v.file)}](${urls[v.file]})`);
219
+ if (links.length)
220
+ out.push('', ...links);
221
+ if (coming && !has.has('images') && !has.has('video'))
222
+ out.push('', coming);
223
+ out.push('');
224
+ }
225
+ if (has.has('images') || has.has('video')) {
226
+ out.push('## Screenshots');
227
+ for (const m of media) {
228
+ const u = urls[m.file];
229
+ if (!u)
230
+ continue;
231
+ out.push(isImage(m) ? `![${inline(m.file)}](${u})` : `[${inline(m.file)}](${u})`);
232
+ }
233
+ if (coming)
234
+ out.push(coming);
235
+ out.push('');
236
+ }
237
+ if (has.has('where')) {
238
+ 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(' · '), '');
239
+ }
240
+ if (hasContext(report)) {
241
+ out.push('## Context (shared so this can be fixed)', '', '| What | Value | Why |', '| --- | --- | --- |');
242
+ if (has.has('app'))
243
+ out.push(`| App | ${cell(`${appName ? `${appName} ` : ''}${text(g.appVersion)}`)} | so the fixer works on the same version you have |`);
244
+ for (const i of report.context.items ?? [])
245
+ out.push(`| ${cell(i.label)} | ${cell(i.value)} | ${cell(i.why)} |`);
246
+ out.push('');
247
+ const block = (k, line) => {
248
+ if (!has.has(k))
249
+ return;
250
+ const rows = shown(k, cut);
251
+ out.push(`<details><summary>${TRAIL_WORDS[k].heading} (${full[k].length})</summary>`, '');
252
+ if (cut[k])
253
+ out.push(`(The ${cut[k]} oldest were cut to fit GitHub's limit.)`, '');
254
+ out.push(...rows.map((x) => line(x)), '', '</details>', '');
255
+ };
256
+ block('routes', (r) => `- ${clock(r.at)} ${inline(r.route)}`);
257
+ block('taps', (t) => `- ${clock(t.at)} ${inline(t.label)}`);
258
+ block('errors', (e) => `- ${clock(e.at)} ${inline(e.message)}${e.frame ? `\n ${inline(e.frame)}` : ''}`);
259
+ }
260
+ else if (has.has('app') || has.has('device') || has.has('time')) {
261
+ const about = [
262
+ has.has('app') ? `App: ${inline(`${appName ? `${appName} ` : ''}${text(g.appVersion)}`)}` : '',
263
+ text(g.device) ? `Device: ${inline(text(g.device))}` : '',
264
+ text(g.screen) ? `Screen ${inline(text(g.screen))}` : '',
265
+ has.has('time') ? `Sent ${when(text(g.at))}` : '',
266
+ ].filter(Boolean);
267
+ out.push('## About', about.join(' · '), '');
268
+ }
269
+ if (has.has('contact') && contact) {
270
+ const ways = [
271
+ contact.email ? `[${linkText(contact.email)}](mailto:${encodeURIComponent(contact.email).replace(/%40/g, '@')})` : '',
272
+ contact.phone ? `[${linkText(contact.phone)}](tel:${contact.phone.replace(/[^\d+]/g, '')})` : '',
273
+ contact.handle ? inline(contact.handle) : '',
274
+ ].filter(Boolean);
275
+ out.push('## Reach them', 'To thank them or ask a follow-up question:', '', ...ways.map((w) => `- ${w}`), '');
276
+ }
277
+ const role = opts.filerRole ?? text(report.who?.role) ?? 'member';
278
+ // "Report <id>" is exact on purpose: the attachment relay reads the issue and requires this line before it saves a file.
279
+ out.push(FILED_BY, filedByLine(opts.filerName ?? report.filerName ?? 'A member', role || 'member', appName, text(report.id)));
280
+ return out.join('\n');
281
+ };
282
+ let said = words;
283
+ let heard = transcript;
284
+ const cut = { routes: 0, taps: 0, errors: 0 };
285
+ let body = build(said, heard, cut);
286
+ // Too long for GitHub: the trail gives way first (taps, then pages, then errors, oldest first), then the transcript,
287
+ // then the words, each with a note saying it was cut to fit.
288
+ for (const k of ['taps', 'routes', 'errors']) {
289
+ while (body.length > BODY_LIMIT && cut[k] < full[k].length) {
290
+ cut[k] += Math.max(1, Math.ceil((full[k].length - cut[k]) / 2));
291
+ body = build(said, heard, cut);
292
+ }
293
+ }
294
+ if (body.length > BODY_LIMIT && heard) {
295
+ const keep = Math.max(0, heard.length - (body.length - BODY_LIMIT) - 200);
296
+ 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.)`;
297
+ body = build(said, heard, cut);
298
+ }
299
+ if (body.length > BODY_LIMIT && said) {
300
+ const keep = Math.max(0, said.length - (body.length - BODY_LIMIT) - 200);
301
+ said = `${said.slice(0, keep)}\n\n(Their words were cut here at ${keep} characters to fit GitHub's limit.)`;
302
+ body = build(said, heard, cut);
303
+ }
304
+ // A title is plain text on GitHub; it still never pings anyone or links an issue.
305
+ const title = clip(firstSentence(words) || firstSentence(transcript) || `Suggestion from ${appName || 'an app'}`, 70).replace(/@(?=[\w-])/g, '@‍').replace(/#(?=\d)/g, '#‍');
306
+ const appLabel = opts.appLabel === null ? '' : opts.appLabel !== undefined ? slug(opts.appLabel) : appName ? slug(appName) : '';
307
+ const labels = [LABEL[text(report.kind)] ?? 'feedback', ...(appLabel ? [`app:${appLabel}`] : [])];
308
+ return { title, body, labels, carries };
309
+ }